Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
593f6f13e7 | ||
|
|
8ee326b43a | ||
|
|
8fb4e714d8 | ||
|
|
a8e69e0850 | ||
|
|
f5b5724e92 | ||
|
|
1e41f31271 | ||
|
|
efd4d03037 | ||
|
|
80759f6793 | ||
|
|
f8279129be |
+1
-1
@@ -127,7 +127,7 @@ class CheckInModuleTest {
|
|||||||
|
|
||||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||||
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
|
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
|
||||||
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Flux.just(mockMemberCard));
|
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard));
|
||||||
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
|
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
|
||||||
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
|
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
|
||||||
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
|
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
|
||||||
|
|||||||
@@ -67,7 +67,12 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.poi</groupId>
|
<groupId>org.apache.poi</groupId>
|
||||||
<artifactId>poi-ooxml</artifactId>
|
<artifactId>poi-ooxml</artifactId>
|
||||||
<version>5.2.5</version>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Fix commons-compress version for POI compatibility -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-compress</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Gym Modules -->
|
<!-- Gym Modules -->
|
||||||
|
|||||||
+6
@@ -59,6 +59,12 @@ public class DataStatistics {
|
|||||||
public static final String WEEK = "WEEK";
|
public static final String WEEK = "WEEK";
|
||||||
/** 月统计 */
|
/** 月统计 */
|
||||||
public static final String MONTH = "MONTH";
|
public static final String MONTH = "MONTH";
|
||||||
|
/** 近30天 */
|
||||||
|
public static final String LAST_30_DAYS = "LAST_30_DAYS";
|
||||||
|
/** 近90天 */
|
||||||
|
public static final String LAST_90_DAYS = "LAST_90_DAYS";
|
||||||
|
/** 今年 */
|
||||||
|
public static final String YEAR = "YEAR";
|
||||||
|
|
||||||
private PeriodType() {}
|
private PeriodType() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -458,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
|||||||
String periodType = query.getPeriodType();
|
String periodType = query.getPeriodType();
|
||||||
|
|
||||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||||
// 周统计:本周一
|
|
||||||
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
||||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||||
// 月统计:本月第一天
|
|
||||||
return today.withDayOfMonth(1).atStartOfDay();
|
return today.withDayOfMonth(1).atStartOfDay();
|
||||||
|
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||||
|
return today.minusDays(29).atStartOfDay();
|
||||||
|
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||||
|
return today.minusDays(89).atStartOfDay();
|
||||||
|
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||||
|
return today.withDayOfYear(1).atStartOfDay();
|
||||||
} else {
|
} else {
|
||||||
// 日统计:当天零点
|
|
||||||
return today.atStartOfDay();
|
return today.atStartOfDay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
|||||||
String periodType = query.getPeriodType();
|
String periodType = query.getPeriodType();
|
||||||
|
|
||||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||||
// 周统计:本周日 23:59:59
|
|
||||||
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
||||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||||
// 月统计:本月最后一天 23:59:59
|
|
||||||
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||||
|
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|
||||||
|
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|
||||||
|
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||||
|
return LocalDateTime.now();
|
||||||
} else {
|
} else {
|
||||||
// 日统计:当前时间
|
|
||||||
return LocalDateTime.now();
|
return LocalDateTime.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
|||||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||||
startTime = date.withDayOfMonth(1).atStartOfDay();
|
startTime = date.withDayOfMonth(1).atStartOfDay();
|
||||||
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||||
|
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||||
|
startTime = date.minusDays(29).atStartOfDay();
|
||||||
|
endTime = date.atTime(23, 59, 59);
|
||||||
|
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||||
|
startTime = date.minusDays(89).atStartOfDay();
|
||||||
|
endTime = date.atTime(23, 59, 59);
|
||||||
|
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||||
|
startTime = date.withDayOfYear(1).atStartOfDay();
|
||||||
|
endTime = date.atTime(23, 59, 59);
|
||||||
} else {
|
} else {
|
||||||
startTime = date.atStartOfDay();
|
startTime = date.atStartOfDay();
|
||||||
endTime = date.plusDays(1).atStartOfDay();
|
endTime = date.plusDays(1).atStartOfDay();
|
||||||
|
|||||||
+6
@@ -61,6 +61,12 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
|
|||||||
*/
|
*/
|
||||||
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计会员取消的预约次数
|
||||||
|
*/
|
||||||
|
@org.springframework.data.r2dbc.repository.Query("SELECT COUNT(*) FROM group_course_booking WHERE member_id = :memberId AND status = '1' AND deleted_at IS NULL")
|
||||||
|
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
||||||
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
||||||
|
|||||||
+95
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.dao;
|
package cn.novalon.gym.manage.groupcourse.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
@@ -226,4 +227,98 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询团课(支持 keyword 和 status 过滤)
|
||||||
|
*/
|
||||||
|
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
conditions.add("course_name ILIKE :keyword");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
conditions.add("status = :status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conditions.isEmpty()) {
|
||||||
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
|
||||||
|
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
|
||||||
|
sql.append(" ORDER BY ").append(sort).append(" ").append(order);
|
||||||
|
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
if (size < 1) size = 10;
|
||||||
|
if (size > 100) size = 100;
|
||||||
|
int offset = pageRequest.getPage() * size;
|
||||||
|
sql.append(" LIMIT :limit OFFSET :offset");
|
||||||
|
|
||||||
|
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
spec = spec.bind("status", pageRequest.getStatus());
|
||||||
|
}
|
||||||
|
spec = spec.bind("limit", size);
|
||||||
|
spec = spec.bind("offset", offset);
|
||||||
|
|
||||||
|
return spec.map((row, meta) -> {
|
||||||
|
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.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询团课总数(支持 keyword 和 status 过滤)
|
||||||
|
*/
|
||||||
|
default Mono<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
conditions.add("course_name ILIKE :keyword");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
conditions.add("status = :status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conditions.isEmpty()) {
|
||||||
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
spec = spec.bind("status", pageRequest.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+64
-67
@@ -9,11 +9,13 @@ 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.member.service.IMemberStoredCardService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,6 +36,7 @@ public class BookingSagaHandler {
|
|||||||
private final IGroupCourseBookingRepository bookingRepository;
|
private final IGroupCourseBookingRepository bookingRepository;
|
||||||
private final IGroupCourseRepository courseRepository;
|
private final IGroupCourseRepository courseRepository;
|
||||||
private final IMemberCardRecordService memberCardRecordService;
|
private final IMemberCardRecordService memberCardRecordService;
|
||||||
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
private final MemberCardRepository memberCardRepository;
|
private final MemberCardRepository memberCardRepository;
|
||||||
private final GroupCourseRedisService redisService;
|
private final GroupCourseRedisService redisService;
|
||||||
|
|
||||||
@@ -44,10 +47,10 @@ public class BookingSagaHandler {
|
|||||||
*
|
*
|
||||||
* 步骤:
|
* 步骤:
|
||||||
* 1. 保存预约记录
|
* 1. 保存预约记录
|
||||||
* 2. 扣减会员卡权益
|
* 2. 扣减储值卡余额(储值卡类型)
|
||||||
* 3. 更新课程当前人数
|
* 3. 更新课程当前人数
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId) {
|
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId, BigDecimal storedValueAmount) {
|
||||||
List<SagaStep> steps = new ArrayList<>();
|
List<SagaStep> steps = new ArrayList<>();
|
||||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||||
|
|
||||||
@@ -60,11 +63,11 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step1);
|
steps.add(step1);
|
||||||
rollbackSteps.add(0, step1);
|
rollbackSteps.add(0, step1);
|
||||||
|
|
||||||
// 步骤2:扣减会员卡权益(根据卡类型决定扣除次数还是金额)
|
// 步骤2:扣减权益(根据卡类型)
|
||||||
SagaStep step2 = new SagaStep(
|
SagaStep step2 = new SagaStep(
|
||||||
"扣减会员卡权益",
|
"扣减权益",
|
||||||
deductCardUsageByCardType(booking.getMemberId(), recordId),
|
deductCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount),
|
||||||
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId))
|
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount))
|
||||||
);
|
);
|
||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
@@ -94,7 +97,7 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 根据会员卡类型扣减权益
|
* 根据会员卡类型扣减权益
|
||||||
*/
|
*/
|
||||||
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId) {
|
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||||
return memberCardRecordService.findById(recordId)
|
return memberCardRecordService.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
@@ -109,14 +112,13 @@ public class BookingSagaHandler {
|
|||||||
|
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case COUNT_CARD:
|
case COUNT_CARD:
|
||||||
// 次数卡不再支持团课预约
|
return Mono.error(new RuntimeException("团课预约仅支持储值卡和时长卡支付"));
|
||||||
return Mono.error(new RuntimeException("团课预约仅支持储值卡支付"));
|
|
||||||
case STORED_VALUE_CARD:
|
case STORED_VALUE_CARD:
|
||||||
// 储值卡扣除金额
|
return deductStoredCardBalance(memberId, storedValueAmount);
|
||||||
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
|
||||||
case TIME_CARD:
|
case TIME_CARD:
|
||||||
// 时长卡不扣除,但需验证有效期
|
// 时长卡验证有效期后,仍须从储值卡扣款
|
||||||
return validateTimeCard(record);
|
return validateTimeCard(record)
|
||||||
|
.then(deductStoredCardBalance(memberId, storedValueAmount));
|
||||||
default:
|
default:
|
||||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||||
}
|
}
|
||||||
@@ -153,11 +155,10 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 根据会员卡类型恢复权益
|
* 根据会员卡类型恢复权益
|
||||||
*/
|
*/
|
||||||
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId) {
|
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||||
return memberCardRecordService.findById(recordId)
|
return memberCardRecordService.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
// 验证会员卡归属(memberId为null时跳过验证)
|
|
||||||
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
||||||
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
||||||
}
|
}
|
||||||
@@ -168,12 +169,29 @@ public class BookingSagaHandler {
|
|||||||
|
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case COUNT_CARD:
|
case COUNT_CARD:
|
||||||
// 次数卡不再支持团课预约,无需恢复
|
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
case STORED_VALUE_CARD:
|
case STORED_VALUE_CARD:
|
||||||
return restoreCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
// 返还储值卡余额(回滚时全额返还)
|
||||||
|
BigDecimal amount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
|
return memberStoredCardService.recharge(memberId, amount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
log.info("储值卡回滚返还成功: memberId={}, amount={}", memberId, amount);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
case TIME_CARD:
|
case TIME_CARD:
|
||||||
return Mono.empty();
|
// 时长卡回滚时也返还储值卡
|
||||||
|
BigDecimal timeAmount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
|
return memberStoredCardService.recharge(memberId, timeAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
log.info("储值卡回滚返还成功(时长卡): memberId={}, amount={}", memberId, timeAmount);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||||
}
|
}
|
||||||
@@ -184,7 +202,7 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 执行取消预约事务
|
* 执行取消预约事务
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId) {
|
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||||
List<SagaStep> steps = new ArrayList<>();
|
List<SagaStep> steps = new ArrayList<>();
|
||||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||||
|
|
||||||
@@ -197,11 +215,12 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step1);
|
steps.add(step1);
|
||||||
rollbackSteps.add(0, step1);
|
rollbackSteps.add(0, step1);
|
||||||
|
|
||||||
// 步骤2:恢复会员卡权益
|
// 步骤2:返还储值卡余额(含手续费逻辑)
|
||||||
SagaStep step2 = new SagaStep(
|
SagaStep step2 = new SagaStep(
|
||||||
"恢复会员卡权益",
|
"返还储值卡余额",
|
||||||
restoreCardUsageByCardType(memberId, recordId),
|
refundStoredCardWithFee(memberId, storedValueAmount, cancelCount),
|
||||||
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId))
|
// 回滚时重新扣减
|
||||||
|
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId, storedValueAmount))
|
||||||
);
|
);
|
||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
@@ -253,54 +272,32 @@ public class BookingSagaHandler {
|
|||||||
return bookingRepository.deleteById(bookingId);
|
return bookingRepository.deleteById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> deductCardUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
/**
|
||||||
if (deductTimes == 0 && deductAmount == 0.0) {
|
* 从储值卡扣减余额
|
||||||
return Mono.empty();
|
*/
|
||||||
}
|
private Mono<Void> deductStoredCardBalance(Long memberId, BigDecimal storedValueAmount) {
|
||||||
|
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||||
return memberCardRecordService.findById(recordId)
|
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
return memberStoredCardService.consume(memberId, amount)
|
||||||
.flatMap(record -> {
|
.flatMap(rows -> {
|
||||||
cn.novalon.gym.manage.member.enums.MemberCardRecordStatus status = record.getStatus();
|
if (rows == 0) {
|
||||||
if (status != cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE) {
|
return Mono.error(new RuntimeException("储值卡扣减失败,余额不足"));
|
||||||
return Mono.error(new RuntimeException("会员卡状态无效,当前状态: " + (status != null ? status.getDesc() : "未知")));
|
|
||||||
}
|
}
|
||||||
java.time.LocalDateTime expireTime = record.getExpireTime();
|
log.info("储值卡扣减成功: memberId={}, amount={}", memberId, amount);
|
||||||
if (expireTime != null && expireTime.isBefore(java.time.LocalDateTime.now())) {
|
return Mono.empty();
|
||||||
return Mono.error(new RuntimeException("会员卡已过期"));
|
|
||||||
}
|
|
||||||
if (record.getRemainingTimes() != null && deductTimes > 0 && record.getRemainingTimes() < deductTimes) {
|
|
||||||
return Mono.error(new RuntimeException("会员卡剩余次数不足,当前剩余: " + record.getRemainingTimes() + "次"));
|
|
||||||
}
|
|
||||||
if (record.getRemainingAmount() != null && deductAmount > 0 && record.getRemainingAmount() < deductAmount) {
|
|
||||||
return Mono.error(new RuntimeException("会员卡余额不足,当前剩余: " + record.getRemainingAmount()));
|
|
||||||
}
|
|
||||||
return memberCardRecordService.deductUsage(recordId, deductTimes, deductAmount)
|
|
||||||
.flatMap(rows -> {
|
|
||||||
if (rows == 0) {
|
|
||||||
return Mono.error(new RuntimeException("扣减会员卡权益失败,请重试"));
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> restoreCardUsage(Long recordId, Integer addTimes, Double addAmount) {
|
/**
|
||||||
if (addTimes == 0 && addAmount == 0.0) {
|
* 返还储值卡余额(含手续费逻辑,仅用于取消预约)
|
||||||
return Mono.empty();
|
*/
|
||||||
}
|
private Mono<Void> refundStoredCardWithFee(Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||||
|
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||||
return memberCardRecordService.findById(recordId)
|
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
return memberStoredCardService.refundBalanceWithFee(memberId, amount, cancelCount)
|
||||||
.flatMap(record -> {
|
.flatMap(refundedAmount -> {
|
||||||
// 使用当前记录的过期时间,避免清空过期时间
|
log.info("储值卡退款成功(含手续费): memberId={}, cancelCount={}, 退款金额={}", memberId, cancelCount, refundedAmount);
|
||||||
return memberCardRecordService.renewCard(recordId, addTimes, addAmount, record.getExpireTime())
|
return Mono.empty();
|
||||||
.flatMap(rows -> {
|
|
||||||
if (rows == 0) {
|
|
||||||
return Mono.error(new RuntimeException("恢复会员卡权益失败,请重试"));
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.handler;
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
|
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -29,6 +30,13 @@ public class CourseLabelHandler {
|
|||||||
.body(courseLabelService.findAll(), CourseLabel.class);
|
.body(courseLabelService.findAll(), CourseLabel.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
|
||||||
|
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PageRequest.class)
|
||||||
|
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
|
||||||
|
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
|
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
|
||||||
public Mono<ServerResponse> getLabelById(ServerRequest request) {
|
public Mono<ServerResponse> getLabelById(ServerRequest request) {
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
@@ -147,17 +155,17 @@ public class CourseLabelHandler {
|
|||||||
return request.bodyToMono(Map.class)
|
return request.bodyToMono(Map.class)
|
||||||
.flatMap(body -> {
|
.flatMap(body -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
|
List<?> rawIds = (List<?>) body.get("labelIds");
|
||||||
|
|
||||||
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
|
if (rawIds == null || rawIds.isEmpty()) {
|
||||||
Map<String, Object> error = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
error.put("success", false);
|
error.put("success", false);
|
||||||
error.put("message", "labelIds不能为空");
|
error.put("message", "labelIds不能为空");
|
||||||
return ServerResponse.badRequest().bodyValue(error);
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Long> labelIds = labelIdsInt.stream()
|
List<Long> labelIds = rawIds.stream()
|
||||||
.map(Integer::longValue)
|
.map(id -> Long.valueOf(String.valueOf(id)))
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
return courseLabelService.addLabelsToType(typeId, labelIds)
|
return courseLabelService.addLabelsToType(typeId, labelIds)
|
||||||
|
|||||||
+70
-31
@@ -2,6 +2,7 @@ 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.service.IGroupCourseBookingService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||||
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
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 org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -23,9 +24,12 @@ import java.util.Map;
|
|||||||
public class GroupCourseBookingHandler {
|
public class GroupCourseBookingHandler {
|
||||||
|
|
||||||
private final IGroupCourseBookingService bookingService;
|
private final IGroupCourseBookingService bookingService;
|
||||||
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
|
|
||||||
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) {
|
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService,
|
||||||
|
IMemberStoredCardService memberStoredCardService) {
|
||||||
this.bookingService = bookingService;
|
this.bookingService = bookingService;
|
||||||
|
this.memberStoredCardService = memberStoredCardService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,24 +49,35 @@ public class GroupCourseBookingHandler {
|
|||||||
if (body.get("memberCardRecordId") == null) {
|
if (body.get("memberCardRecordId") == null) {
|
||||||
return buildErrorResponse("请提供会员卡记录ID");
|
return buildErrorResponse("请提供会员卡记录ID");
|
||||||
}
|
}
|
||||||
|
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
|
||||||
|
return buildErrorResponse("请输入支付密码");
|
||||||
|
}
|
||||||
|
|
||||||
Long courseId = ((Number) body.get("courseId")).longValue();
|
Long courseId = toLong(body.get("courseId"), "courseId");
|
||||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||||
Long memberCardRecordId = ((Number) body.get("memberCardRecordId")).longValue();
|
Long memberCardRecordId = toLong(body.get("memberCardRecordId"), "memberCardRecordId");
|
||||||
|
String payPassword = body.get("payPassword").toString();
|
||||||
|
|
||||||
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
// 验证支付密码
|
||||||
.flatMap(booking -> {
|
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
|
||||||
Map<String, Object> response = new HashMap<>();
|
.flatMap(passwordValid -> {
|
||||||
response.put("success", true);
|
if (!passwordValid) {
|
||||||
response.put("message", "预约成功");
|
return buildErrorResponse("支付密码错误");
|
||||||
response.put("data", booking);
|
}
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
||||||
})
|
.flatMap(booking -> {
|
||||||
.onErrorResume(error -> {
|
Map<String, Object> response = new HashMap<>();
|
||||||
Map<String, Object> response = new HashMap<>();
|
response.put("success", true);
|
||||||
response.put("success", false);
|
response.put("message", "预约成功");
|
||||||
response.put("message", error.getMessage());
|
response.put("data", booking);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", error.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,21 +91,32 @@ public class GroupCourseBookingHandler {
|
|||||||
|
|
||||||
return request.bodyToMono(Map.class)
|
return request.bodyToMono(Map.class)
|
||||||
.flatMap(body -> {
|
.flatMap(body -> {
|
||||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||||
|
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
|
||||||
|
return buildErrorResponse("请输入支付密码");
|
||||||
|
}
|
||||||
|
String payPassword = body.get("payPassword").toString();
|
||||||
|
|
||||||
return bookingService.cancelBooking(bookingId, memberId)
|
// 验证支付密码
|
||||||
.flatMap(booking -> {
|
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
|
||||||
Map<String, Object> response = new HashMap<>();
|
.flatMap(passwordValid -> {
|
||||||
response.put("success", true);
|
if (!passwordValid) {
|
||||||
response.put("message", "取消成功");
|
return buildErrorResponse("支付密码错误");
|
||||||
response.put("data", booking);
|
}
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return bookingService.cancelBooking(bookingId, memberId)
|
||||||
})
|
.flatMap(booking -> {
|
||||||
.onErrorResume(error -> {
|
Map<String, Object> response = new HashMap<>();
|
||||||
Map<String, Object> response = new HashMap<>();
|
response.put("success", true);
|
||||||
response.put("success", false);
|
response.put("message", "取消成功");
|
||||||
response.put("message", error.getMessage());
|
response.put("data", booking);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", false);
|
||||||
|
response.put("message", error.getMessage());
|
||||||
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,6 +155,19 @@ public class GroupCourseBookingHandler {
|
|||||||
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全转换为 Long,支持 Number 和 String 类型
|
||||||
|
*/
|
||||||
|
private Long toLong(Object value, String fieldName) {
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String) {
|
||||||
|
return Long.valueOf((String) value);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("参数 " + fieldName + " 类型不正确");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建错误响应
|
* 构建错误响应
|
||||||
*/
|
*/
|
||||||
|
|||||||
+9
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.handler;
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -134,4 +135,12 @@ public class GroupCourseTypeHandler {
|
|||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
|
||||||
|
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PageRequest.class)
|
||||||
|
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
|
||||||
|
.flatMap(response -> ServerResponse.ok().bodyValue(response))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository;
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -29,4 +31,6 @@ public interface ICourseLabelRepository {
|
|||||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||||
|
|
||||||
Mono<Void> clearLabelsFromType(Long typeId);
|
Mono<Void> clearLabelsFromType(Long typeId);
|
||||||
|
|
||||||
|
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+7
@@ -89,4 +89,11 @@ public interface IGroupCourseBookingRepository {
|
|||||||
* @return 更新记录数
|
* @return 更新记录数
|
||||||
*/
|
*/
|
||||||
Mono<Integer> updateToAbsent(Long bookingId);
|
Mono<Integer> updateToAbsent(Long bookingId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计会员取消的预约次数
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 取消次数
|
||||||
|
*/
|
||||||
|
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository;
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository {
|
|||||||
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
|
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
|
||||||
|
|
||||||
Mono<Void> deleteById(Long id);
|
Mono<Void> deleteById(Long id);
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+47
-1
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
|
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
|
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
|
||||||
@@ -9,12 +11,17 @@ import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
|
import org.springframework.data.relational.core.query.Criteria;
|
||||||
|
import org.springframework.data.relational.core.query.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
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.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
|||||||
private final CourseLabelDao courseLabelDao;
|
private final CourseLabelDao courseLabelDao;
|
||||||
private final CourseTypeLabelDao courseTypeLabelDao;
|
private final CourseTypeLabelDao courseTypeLabelDao;
|
||||||
private final GroupCourseConverter converter;
|
private final GroupCourseConverter converter;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
|
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
|
||||||
GroupCourseConverter converter) {
|
GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||||
this.courseLabelDao = courseLabelDao;
|
this.courseLabelDao = courseLabelDao;
|
||||||
this.courseTypeLabelDao = courseTypeLabelDao;
|
this.courseTypeLabelDao = courseTypeLabelDao;
|
||||||
this.converter = converter;
|
this.converter = converter;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
|||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
String keyword = pageRequest.getKeyword();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Criteria> criteriaList = new ArrayList<>();
|
||||||
|
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||||
|
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
|
||||||
|
.matching(query)
|
||||||
|
.all()
|
||||||
|
.collectList()
|
||||||
|
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
|
||||||
|
.map(tuple -> {
|
||||||
|
long total = tuple.getT2();
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
List<CourseLabel> list = tuple.getT1().stream()
|
||||||
|
.map(this::toCourseLabel)
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(list, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+5
@@ -112,4 +112,9 @@ public class GroupCourseBookingRepository implements IGroupCourseBookingReposito
|
|||||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||||
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Long> countCancelledByMemberId(Long memberId) {
|
||||||
|
return groupCourseBookingDao.countCancelledByMemberId(memberId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
-29
@@ -97,37 +97,19 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
||||||
int page = pageRequest.getPage();
|
int page = pageRequest.getPage();
|
||||||
int size = pageRequest.getSize();
|
int size = pageRequest.getSize();
|
||||||
String sort = pageRequest.getSort();
|
|
||||||
String order = pageRequest.getOrder();
|
|
||||||
|
|
||||||
Sort sortObj = Sort.unsorted();
|
return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||||
if (sort != null && !sort.isEmpty()) {
|
.flatMap(total -> {
|
||||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
if (total == 0) {
|
||||||
}
|
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
|
||||||
|
|
||||||
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
|
|
||||||
|
|
||||||
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
|
|
||||||
.collectList()
|
|
||||||
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
|
|
||||||
.map(tuple -> {
|
|
||||||
List<GroupCourseEntity> allEntities = tuple.getT1();
|
|
||||||
long total = tuple.getT2();
|
|
||||||
|
|
||||||
int fromIndex = page * size;
|
|
||||||
int toIndex = Math.min(fromIndex + size, allEntities.size());
|
|
||||||
|
|
||||||
List<GroupCourse> courseList;
|
|
||||||
if (fromIndex < allEntities.size()) {
|
|
||||||
courseList = allEntities.subList(fromIndex, toIndex).stream()
|
|
||||||
.map(groupCourseConverter::toDomain)
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
courseList = List.of();
|
|
||||||
}
|
}
|
||||||
|
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||||
int totalPages = (int) Math.ceil((double) total / size);
|
.map(groupCourseConverter::toDomain)
|
||||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
.collectList()
|
||||||
|
.map(courseList -> {
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-1
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
@@ -7,12 +9,18 @@ import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
|
import org.springframework.data.relational.core.query.Criteria;
|
||||||
|
import org.springframework.data.relational.core.query.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
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.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
|||||||
|
|
||||||
private final GroupCourseTypeDao groupCourseTypeDao;
|
private final GroupCourseTypeDao groupCourseTypeDao;
|
||||||
private final GroupCourseConverter converter;
|
private final GroupCourseConverter converter;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
|
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter,
|
||||||
|
R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||||
this.groupCourseTypeDao = groupCourseTypeDao;
|
this.groupCourseTypeDao = groupCourseTypeDao;
|
||||||
this.converter = converter;
|
this.converter = converter;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -129,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
|||||||
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
|
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String keyword = pageRequest.getKeyword();
|
||||||
|
String category = pageRequest.getCategory();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic criteria
|
||||||
|
List<Criteria> criteriaList = new ArrayList<>();
|
||||||
|
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
|
||||||
|
}
|
||||||
|
if (category != null && !category.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("category").is(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||||
|
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
|
||||||
|
.matching(query)
|
||||||
|
.all()
|
||||||
|
.collectList()
|
||||||
|
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
|
||||||
|
.map(tuple -> {
|
||||||
|
long total = tuple.getT2();
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
List<GroupCourseType> list = tuple.getT1().stream()
|
||||||
|
.map(converter::toGroupCourseType)
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(list, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service;
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -27,4 +29,6 @@ public interface ICourseLabelService {
|
|||||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||||
|
|
||||||
Mono<Void> clearLabelsFromType(Long typeId);
|
Mono<Void> clearLabelsFromType(Long typeId);
|
||||||
|
|
||||||
|
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service;
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
|
|||||||
* @return 分类名称列表
|
* @return 分类名称列表
|
||||||
*/
|
*/
|
||||||
Flux<String> findCategories();
|
Flux<String> findCategories();
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+10
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
@@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService {
|
|||||||
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
|
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
|
||||||
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
|
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||||
|
return courseLabelRepository.findByPage(pageRequest)
|
||||||
|
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
|
||||||
|
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||||
|
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+12
-3
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -157,7 +158,8 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
booking.setLocation(course.getLocation());
|
booking.setLocation(course.getLocation());
|
||||||
|
|
||||||
// 10. 使用Saga事务执行预约(包含权益扣减)
|
// 10. 使用Saga事务执行预约(包含权益扣减)
|
||||||
return bookingSagaHandler.executeBooking(booking, memberCardRecordId)
|
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.ZERO;
|
||||||
|
return bookingSagaHandler.executeBooking(booking, memberCardRecordId, courseAmount)
|
||||||
.flatMap(savedBooking -> {
|
.flatMap(savedBooking -> {
|
||||||
// 11. 释放锁
|
// 11. 释放锁
|
||||||
return redisService.releaseLock(courseId, requestId)
|
return redisService.releaseLock(courseId, requestId)
|
||||||
@@ -290,8 +292,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 使用Saga事务执行取消预约(包含权益恢复)
|
// 5. 查询课程金额和已取消次数,使用Saga事务执行取消预约
|
||||||
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId)
|
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
|
||||||
|
.flatMap(course -> {
|
||||||
|
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.valueOf(50);
|
||||||
|
return bookingRepository.countCancelledByMemberId(memberId)
|
||||||
|
.flatMap(cancelCount -> {
|
||||||
|
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId, courseAmount, cancelCount);
|
||||||
|
});
|
||||||
|
})
|
||||||
.flatMap(updatedBooking -> {
|
.flatMap(updatedBooking -> {
|
||||||
// 6. 释放锁
|
// 6. 释放锁
|
||||||
return redisService.releaseLock(bookingId, requestId)
|
return redisService.releaseLock(bookingId, requestId)
|
||||||
|
|||||||
+8
-6
@@ -224,8 +224,9 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
String sort = pageRequest.getSort();
|
String sort = pageRequest.getSort();
|
||||||
String order = pageRequest.getOrder();
|
String order = pageRequest.getOrder();
|
||||||
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
||||||
|
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
|
||||||
|
|
||||||
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
|
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
|
||||||
|
|
||||||
return redisUtil.get(cacheKey, String.class)
|
return redisUtil.get(cacheKey, String.class)
|
||||||
.flatMap(cachedJson -> {
|
.flatMap(cachedJson -> {
|
||||||
@@ -536,14 +537,15 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> delete(Long id) {
|
public Mono<Void> delete(Long id) {
|
||||||
// 先查询课程状态,只有已取消的课程才能删除
|
// 只有已取消或已结束的课程才能删除
|
||||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||||
.flatMap(course -> {
|
.flatMap(course -> {
|
||||||
// 检查课程状态是否为已取消(状态码1)
|
// 检查课程状态是否为已取消(状态码1)或已结束(状态码2)
|
||||||
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
Long status = course.getStatus();
|
||||||
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " +
|
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
|
||||||
(course.getStatus() != null ? course.getStatus() : "未知")));
|
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
|
||||||
|
(status != null ? status : "未知")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除课程
|
// 删除课程
|
||||||
|
|||||||
+26
-7
@@ -1,6 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
|
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.IGroupCourseTypeService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -9,18 +12,18 @@ import org.springframework.stereotype.Service;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
||||||
|
|
||||||
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
||||||
|
private final IGroupCourseRepository groupCourseRepository;
|
||||||
|
|
||||||
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
|
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
|
||||||
|
IGroupCourseRepository groupCourseRepository) {
|
||||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||||
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -71,9 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> delete(Long id) {
|
public Mono<Void> delete(Long id) {
|
||||||
return groupCourseTypeRepository.deleteById(id)
|
// 检查是否有团课依赖该类型
|
||||||
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
return groupCourseRepository.findByCourseType(id)
|
||||||
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
.hasElements()
|
||||||
|
.flatMap(hasDependents -> {
|
||||||
|
if (hasDependents) {
|
||||||
|
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
|
||||||
|
}
|
||||||
|
return groupCourseTypeRepository.deleteById(id)
|
||||||
|
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
||||||
|
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -83,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
|||||||
.filter(category -> category != null && !category.isEmpty())
|
.filter(category -> category != null && !category.isEmpty())
|
||||||
.distinct();
|
.distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||||
|
return groupCourseTypeRepository.findByPage(pageRequest)
|
||||||
|
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
|
||||||
|
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||||
|
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+17
-6
@@ -63,13 +63,24 @@ public class MemberCardHandler {
|
|||||||
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
|
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
|
||||||
public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
|
public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
return request.bodyToMono(MemberCard.class)
|
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
||||||
.flatMap(card -> {
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
|
||||||
card.setMemberCardId(id);
|
.zipWith(request.bodyToMono(MemberCard.class))
|
||||||
return memberCardService.save(card);
|
.flatMap(tuple -> {
|
||||||
|
MemberCard existing = tuple.getT1();
|
||||||
|
MemberCard body = tuple.getT2();
|
||||||
|
// 仅更新非 null 字段,避免 null 覆盖已有值
|
||||||
|
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
|
||||||
|
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
|
||||||
|
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
|
||||||
|
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
|
||||||
|
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
|
||||||
|
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
|
||||||
|
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
|
||||||
|
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
|
||||||
|
return memberCardService.save(existing);
|
||||||
})
|
})
|
||||||
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
|
.flatMap(updated -> ServerResponse.ok().bodyValue(updated));
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
|
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
|
||||||
|
|||||||
+11
-9
@@ -155,8 +155,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
||||||
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", detail.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
|
||||||
detail.setPhone(null);
|
detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ServerResponse.ok()
|
return ServerResponse.ok()
|
||||||
@@ -204,8 +204,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||||
member.setPhone(null);
|
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
@@ -222,11 +222,13 @@ public class MemberHandler {
|
|||||||
|
|
||||||
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||||
|
String sortField = request.queryParam("sortField").orElse(null);
|
||||||
|
String sortOrder = request.queryParam("sortOrder").orElse(null);
|
||||||
|
|
||||||
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
|
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
|
||||||
// TODO: 补充签到记录
|
adminId, pageNum, pageSize, sortField, sortOrder);
|
||||||
|
|
||||||
return memberService.findAll(pageNum, pageSize)
|
return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
|
||||||
.map(member -> {
|
.map(member -> {
|
||||||
// 解密手机号
|
// 解密手机号
|
||||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||||
@@ -234,8 +236,8 @@ public class MemberHandler {
|
|||||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||||
member.setPhone(null);
|
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return member;
|
return member;
|
||||||
|
|||||||
+12
@@ -110,6 +110,18 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
|||||||
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
|
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
|
||||||
Flux<MemberCardRecord> findExpiredCards();
|
Flux<MemberCardRecord> findExpiredCards();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据支付订单ID查询会员卡记录(用于幂等性检查)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card_record WHERE source_order_id = :sourceOrderId AND deleted_at IS NULL")
|
||||||
|
Mono<MemberCardRecord> findBySourceOrderId(Long sourceOrderId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员在指定时间后购买的同类活跃卡(防前端重复调用)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND member_card_id = :memberCardId AND status = 'ACTIVE' AND deleted_at IS NULL AND purchase_time > :since ORDER BY purchase_time DESC LIMIT 1")
|
||||||
|
Mono<MemberCardRecord> findRecentActivePurchase(Long memberId, Long memberCardId, LocalDateTime since);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询所有有效记录
|
* 查询所有有效记录
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
|
|||||||
"AND (:type IS NULL OR member_card_type = :type) " +
|
"AND (:type IS NULL OR member_card_type = :type) " +
|
||||||
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
|
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
|
||||||
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
|
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
|
||||||
"ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
"ORDER BY id ASC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
||||||
Flux<MemberCard> findWithConditions(Integer status, String name, String type,
|
Flux<MemberCard> findWithConditions(Integer status, String name, String type,
|
||||||
Double minPrice, Double maxPrice, Pageable pageable);
|
Double minPrice, Double maxPrice, Pageable pageable);
|
||||||
|
|
||||||
|
|||||||
+8
@@ -34,6 +34,14 @@ public interface MemberStoredCardRepository extends R2dbcRepository<MemberStored
|
|||||||
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount);
|
Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还 - 增加余额(用于取消预约退款)
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, updated_at = NOW() " +
|
||||||
|
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> refundBalance(Long memberId, BigDecimal addAmount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 储值卡消费 - 扣减余额
|
* 储值卡消费 - 扣减余额
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
@@ -52,6 +52,17 @@ public interface IMemberStoredCardService {
|
|||||||
*/
|
*/
|
||||||
Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount);
|
Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还(取消预约退款,仅增加余额不修改充值总额)
|
||||||
|
*/
|
||||||
|
Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还(内部调用,无需密码,含手续费逻辑)
|
||||||
|
* @return 实际返还金额
|
||||||
|
*/
|
||||||
|
Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询会员的充值记录列表
|
* 查询会员的充值记录列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ public interface MemberService {
|
|||||||
* @param pageSize 页大小
|
* @param pageSize 页大小
|
||||||
* @return 所有会员信息
|
* @return 所有会员信息
|
||||||
*/
|
*/
|
||||||
Flux<Member> findAll(Integer pageNum, Integer pageSize);
|
Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前台管理端获取会员详情(含会员卡信息)
|
* 前台管理端获取会员详情(含会员卡信息)
|
||||||
|
|||||||
+5
-5
@@ -213,9 +213,9 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
.filter(record -> record.getMemberCardType() == null ||
|
.filter(record -> record.getMemberCardType() == null ||
|
||||||
!record.getMemberCardType().equals("STORED_VALUE_CARD"))
|
!record.getMemberCardType().equals("STORED_VALUE_CARD"))
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(records -> {
|
.flatMap(records -> {
|
||||||
if (records.isEmpty()) {
|
if (records.isEmpty()) {
|
||||||
return null;
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先找临期卡(剩余1-3天)
|
// 优先找临期卡(剩余1-3天)
|
||||||
@@ -230,7 +230,7 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (expiringCard != null) {
|
if (expiringCard != null) {
|
||||||
return expiringCard;
|
return Mono.just(expiringCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再找普通有效卡(剩余>3天)
|
// 再找普通有效卡(剩余>3天)
|
||||||
@@ -245,11 +245,11 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (activeCard != null) {
|
if (activeCard != null) {
|
||||||
return activeCard;
|
return Mono.just(activeCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后返回第一张卡
|
// 最后返回第一张卡
|
||||||
return records.get(0);
|
return Mono.just(records.get(0));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -44,6 +44,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
||||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
private static final long DUPLICATE_WINDOW_SECONDS = 10;
|
||||||
|
|
||||||
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
||||||
MemberCardRecordRepository recordRepository,
|
MemberCardRecordRepository recordRepository,
|
||||||
@@ -130,6 +131,30 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
if (memberCardId == null) {
|
if (memberCardId == null) {
|
||||||
return Mono.error(new RuntimeException("会员卡类型ID不能为空"));
|
return Mono.error(new RuntimeException("会员卡类型ID不能为空"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速路径:幂等性检查,同一笔支付订单已创建过记录则直接返回
|
||||||
|
if (sourceOrderId != null) {
|
||||||
|
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("支付订单已生成会员卡记录,幂等返回: sourceOrderId={}, memberCardRecordId={}",
|
||||||
|
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceOrderId为null(前端直接调用):检查10秒内是否已有同类卡,防前端重复调用
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusSeconds(DUPLICATE_WINDOW_SECONDS);
|
||||||
|
return recordRepository.findRecentActivePurchase(memberId, memberCardId, since)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("前端重复购买检测命中: memberId={}, memberCardId={}, memberCardRecordId={}, purchaseTime={}",
|
||||||
|
memberId, memberCardId, existingRecord.getMemberCardRecordId(), existingRecord.getPurchaseTime());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MemberCardRecord> doPurchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
||||||
// 前端传的是数据库主键id,不是业务字段member_card_id
|
// 前端传的是数据库主键id,不是业务字段member_card_id
|
||||||
return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId)
|
return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||||
@@ -143,8 +168,19 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
return distributedLockService.executeWithLock(
|
return distributedLockService.executeWithLock(
|
||||||
memberId.toString(),
|
memberId.toString(),
|
||||||
cardType.name(),
|
cardType.name(),
|
||||||
Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card))
|
Mono.defer(() -> {
|
||||||
// 修改:将 card 对象传递给 createTransaction 以获取购买金额
|
// 持锁后再次检查幂等,防止并发请求同时通过外层检查
|
||||||
|
if (sourceOrderId != null) {
|
||||||
|
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("持锁幂等检查命中: sourceOrderId={}, memberCardRecordId={}",
|
||||||
|
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)));
|
||||||
|
}
|
||||||
|
return createCardRecord(memberId, memberCardId, sourceOrderId, card);
|
||||||
|
})
|
||||||
.flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡")
|
.flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡")
|
||||||
.thenReturn(record))
|
.thenReturn(record))
|
||||||
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
||||||
|
|||||||
+62
-42
@@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@@ -230,13 +231,31 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */
|
||||||
|
private static final java.util.Map<String, String> ALLOWED_SORT_FIELDS = java.util.Map.of(
|
||||||
|
"createdAt", "created_at",
|
||||||
|
"memberNo", "member_no",
|
||||||
|
"gender", "gender",
|
||||||
|
"id", "id"
|
||||||
|
);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<Member> findAll(Integer pageNum, Integer pageSize) {
|
public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
|
||||||
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize);
|
log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
|
||||||
|
pageNum, pageSize, sortField, sortOrder);
|
||||||
|
|
||||||
|
// 排序方向:desc(默认) / asc
|
||||||
|
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
|
||||||
|
? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||||
|
|
||||||
|
// 排序字段白名单校验,不在白名单中则使用默认排序
|
||||||
|
String dbField = (sortField != null && ALLOWED_SORT_FIELDS.containsKey(sortField))
|
||||||
|
? ALLOWED_SORT_FIELDS.get(sortField) : "created_at";
|
||||||
|
|
||||||
Pageable pageable = PageRequest.of(
|
Pageable pageable = PageRequest.of(
|
||||||
pageNum - 1,
|
pageNum - 1,
|
||||||
pageSize
|
pageSize,
|
||||||
|
Sort.by(direction, dbField)
|
||||||
);
|
);
|
||||||
|
|
||||||
return memberRepository.findAllBy(pageable);
|
return memberRepository.findAllBy(pageable);
|
||||||
@@ -249,50 +268,51 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
||||||
|
|
||||||
return redisUtil.get(cacheKey, MemberDetailVO.class)
|
return redisUtil.get(cacheKey, MemberDetailVO.class)
|
||||||
.flatMap(cached -> {
|
.filter(cached -> cached != null)
|
||||||
if (cached != null) {
|
.switchIfEmpty(Mono.defer(() ->
|
||||||
log.debug("从缓存获取会员详情, memberId: {}", memberId);
|
memberRepository.findById(memberId)
|
||||||
return Mono.just(cached);
|
.switchIfEmpty(Mono.error(() -> {
|
||||||
}
|
log.error("会员不存在: memberId={}", memberId);
|
||||||
return memberRepository.findById(memberId)
|
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||||
.zipWith(
|
}))
|
||||||
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
.zipWith(
|
||||||
.collectList(),
|
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
||||||
(baseInfo, cardList) -> {
|
.collectList(),
|
||||||
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
(baseInfo, cardList) -> {
|
||||||
|
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
||||||
|
|
||||||
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
||||||
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
||||||
|
|
||||||
List<MemberCardInfoVO> enrichedCards = cardList.stream()
|
List<MemberCardInfoVO> enrichedCards = cardList.stream()
|
||||||
.peek(vo -> {
|
.peek(vo -> {
|
||||||
if (vo.getMemberCardType() != null) {
|
if (vo.getMemberCardType() != null) {
|
||||||
try {
|
try {
|
||||||
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
|
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
|
||||||
vo.setMemberCardTypeDesc(cardType.getDesc());
|
vo.setMemberCardTypeDesc(cardType.getDesc());
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
vo.setMemberCardTypeDesc(vo.getMemberCardType());
|
vo.setMemberCardTypeDesc(vo.getMemberCardType());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (vo.getMemberCardStatus() != null) {
|
||||||
if (vo.getMemberCardStatus() != null) {
|
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
|
||||||
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
|
}
|
||||||
}
|
})
|
||||||
})
|
.collect(Collectors.toList());
|
||||||
.collect(Collectors.toList());
|
memberDetailVO.setMemberCards(enrichedCards);
|
||||||
memberDetailVO.setMemberCards(enrichedCards);
|
|
||||||
|
|
||||||
long activeCount = enrichedCards.stream()
|
long activeCount = enrichedCards.stream()
|
||||||
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
|
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
|
||||||
.count();
|
.count();
|
||||||
memberDetailVO.setActiveCardCount((int) activeCount);
|
memberDetailVO.setActiveCardCount((int) activeCount);
|
||||||
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
|
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
|
||||||
|
|
||||||
return memberDetailVO;
|
return memberDetailVO;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||||
.then(Mono.just(vo)));
|
.then(Mono.just(vo)))
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+87
-1
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
|||||||
import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository;
|
import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository;
|
||||||
import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository;
|
import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
|
import cn.novalon.gym.manage.sys.core.service.ISysConfigService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -22,12 +23,15 @@ public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
|
|||||||
|
|
||||||
private final MemberStoredCardRepository memberStoredCardRepository;
|
private final MemberStoredCardRepository memberStoredCardRepository;
|
||||||
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
||||||
|
private final ISysConfigService sysConfigService;
|
||||||
private final BCryptPasswordEncoder passwordEncoder;
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository,
|
public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository,
|
||||||
MemberStoredCardRechargeRepository memberStoredCardRechargeRepository) {
|
MemberStoredCardRechargeRepository memberStoredCardRechargeRepository,
|
||||||
|
ISysConfigService sysConfigService) {
|
||||||
this.memberStoredCardRepository = memberStoredCardRepository;
|
this.memberStoredCardRepository = memberStoredCardRepository;
|
||||||
this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository;
|
this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository;
|
||||||
|
this.sysConfigService = sysConfigService;
|
||||||
this.passwordEncoder = new BCryptPasswordEncoder();
|
this.passwordEncoder = new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +141,94 @@ public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount) {
|
||||||
|
// 从系统配置读取免手续费次数阈值,默认3次
|
||||||
|
Mono<Long> freeLimitMono = sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.defaultIfEmpty(3L);
|
||||||
|
|
||||||
|
return freeLimitMono.flatMap(freeLimit ->
|
||||||
|
memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
// 验证密码
|
||||||
|
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
|
||||||
|
log.warn("[储值卡退款] 未设置支付密码: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("请先设置支付密码"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(password, card.getPayPassword())) {
|
||||||
|
log.warn("[储值卡退款] 密码错误: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("支付密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前N次取消不扣手续费,超出后扣除10%
|
||||||
|
BigDecimal refundAmount = amount;
|
||||||
|
BigDecimal fee = BigDecimal.ZERO;
|
||||||
|
if (cancelCount >= freeLimit) {
|
||||||
|
fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
refundAmount = amount.subtract(fee);
|
||||||
|
log.info("[储值卡退款] 第{}次取消,已超出免手续费阈值({}次),扣除10%手续费: 原额={}, 手续费={}, 退款={}",
|
||||||
|
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||||
|
} else {
|
||||||
|
log.info("[储值卡退款] 第{}次取消,在免手续费阈值({}次)内,全额退款: 原额={}",
|
||||||
|
cancelCount + 1, freeLimit, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行退款
|
||||||
|
BigDecimal finalRefundAmount = refundAmount;
|
||||||
|
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.map(updatedCard -> PayResult.success(
|
||||||
|
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
|
||||||
|
} else {
|
||||||
|
log.warn("[储值卡退款] 退款失败: memberId={}, amount={}", memberId, finalRefundAmount);
|
||||||
|
return Mono.just(PayResult.fail("退款失败,请稍后重试"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.warn("[储值卡退款] 储值卡不存在: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("储值卡不存在"));
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) {
|
public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) {
|
||||||
int offset = (page - 1) * size;
|
int offset = (page - 1) * size;
|
||||||
return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset);
|
return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount) {
|
||||||
|
return sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.defaultIfEmpty(3L)
|
||||||
|
.flatMap(freeLimit -> {
|
||||||
|
BigDecimal refundAmount;
|
||||||
|
if (cancelCount >= freeLimit) {
|
||||||
|
BigDecimal fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
refundAmount = amount.subtract(fee);
|
||||||
|
log.info("[储值卡退款-内部] 第{}次取消,超出免手续费阈值({}次),扣除10%: 原额={}, 手续费={}, 退款={}",
|
||||||
|
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||||
|
} else {
|
||||||
|
refundAmount = amount;
|
||||||
|
log.info("[储值卡退款-内部] 第{}次取消,在免手续费阈值({}次)内,全额退款: {}",
|
||||||
|
cancelCount + 1, freeLimit, amount);
|
||||||
|
}
|
||||||
|
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
return Mono.just(refundAmount);
|
||||||
|
}
|
||||||
|
return Mono.error(new RuntimeException("储值卡退款失败"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Long> countRechargeRecords(Long memberId) {
|
public Mono<Long> countRechargeRecords(Long memberId) {
|
||||||
return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId);
|
return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId);
|
||||||
|
|||||||
+6
-8
@@ -222,28 +222,26 @@ public class PaymentServiceImpl implements PaymentService {
|
|||||||
if ("00000000".equals(respCode)) {
|
if ("00000000".equals(respCode)) {
|
||||||
if ("S".equals(transStat)) {
|
if ("S".equals(transStat)) {
|
||||||
if (!"SUCCESS".equals(order.getPayStatus())) {
|
if (!"SUCCESS".equals(order.getPayStatus())) {
|
||||||
log.info("[Huifu] 查询到支付成功,更新订单状态并触发业务处理, orderNo={}, orderType={}",
|
log.info("[Huifu] 查询到支付成功,更新订单状态并触发业务通知, orderNo={}", order.getOrderNo());
|
||||||
order.getOrderNo(), order.getOrderType());
|
|
||||||
order.setPayStatus("SUCCESS");
|
order.setPayStatus("SUCCESS");
|
||||||
order.setHfSeqId(String.valueOf(response.getOrDefault("hf_seq_id", order.getHfSeqId())));
|
order.setHfSeqId(String.valueOf(response.getOrDefault("hf_seq_id", order.getHfSeqId())));
|
||||||
order.setPayTime(LocalDateTime.now());
|
order.setPayTime(LocalDateTime.now());
|
||||||
return paymentOrderRepository.save(order)
|
return paymentOrderRepository.save(order)
|
||||||
.flatMap(savedOrder -> {
|
.flatMap(savedOrder -> {
|
||||||
log.info("[Huifu] 订单状态已更新,开始触发业务处理, orderNo={}", savedOrder.getOrderNo());
|
log.info("[Huifu] 订单状态已更新为SUCCESS, orderNo={}", savedOrder.getOrderNo());
|
||||||
return paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "SUCCESS", memberId)
|
return paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "SUCCESS", savedOrder.getMemberId())
|
||||||
.thenReturn(convertToResponse(savedOrder));
|
.thenReturn(convertToResponse(savedOrder));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
log.info("[Huifu] 订单已处理过,跳过业务处理, orderNo={}", order.getOrderNo());
|
log.info("[Huifu] 订单已处理过,跳过, orderNo={}", order.getOrderNo());
|
||||||
}
|
}
|
||||||
} else if ("F".equals(transStat)) {
|
} else if ("F".equals(transStat)) {
|
||||||
if (!"FAIL".equals(order.getPayStatus())) {
|
if (!"FAIL".equals(order.getPayStatus())) {
|
||||||
log.info("[Huifu] 查询到支付失败,更新订单状态, orderNo={}", order.getOrderNo());
|
log.info("[Huifu] 查询到支付失败,仅更新订单状态, orderNo={}", order.getOrderNo());
|
||||||
order.setPayStatus("FAIL");
|
order.setPayStatus("FAIL");
|
||||||
order.setErrorMsg(String.valueOf(response.getOrDefault("resp_desc", "支付失败")));
|
order.setErrorMsg(String.valueOf(response.getOrDefault("resp_desc", "支付失败")));
|
||||||
return paymentOrderRepository.save(order)
|
return paymentOrderRepository.save(order)
|
||||||
.flatMap(savedOrder -> paymentNotifyService.notifyPaymentStatus(savedOrder.getOrderNo(), "FAIL", memberId)
|
.map(savedOrder -> convertToResponse(savedOrder));
|
||||||
.thenReturn(convertToResponse(savedOrder)));
|
|
||||||
}
|
}
|
||||||
} else if ("P".equals(transStat)) {
|
} else if ("P".equals(transStat)) {
|
||||||
order.setPayStatus("PENDING");
|
order.setPayStatus("PENDING");
|
||||||
|
|||||||
+2915
-942
File diff suppressed because it is too large
Load Diff
+5
@@ -257,8 +257,11 @@ public class SystemRouter {
|
|||||||
|
|
||||||
// ===== 会员卡类型管理 =====
|
// ===== 会员卡类型管理 =====
|
||||||
.GET("/api/member-cards/active", memberCardHandler::getActiveCards)
|
.GET("/api/member-cards/active", memberCardHandler::getActiveCards)
|
||||||
|
.GET("/api/member-cards", memberCardHandler::listMemberCards)
|
||||||
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
|
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
|
||||||
.POST("/api/member-cards", memberCardHandler::createMemberCard)
|
.POST("/api/member-cards", memberCardHandler::createMemberCard)
|
||||||
|
.PUT("/api/member-cards/{id}", memberCardHandler::updateMemberCard)
|
||||||
|
.DELETE("/api/member-cards/{id}", memberCardHandler::deleteMemberCard)
|
||||||
|
|
||||||
// ===== 会员卡记录管理(核心业务)=====
|
// ===== 会员卡记录管理(核心业务)=====
|
||||||
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
|
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
|
||||||
@@ -305,6 +308,7 @@ public class SystemRouter {
|
|||||||
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
|
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
|
||||||
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
|
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
|
||||||
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
|
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
|
||||||
|
.POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage)
|
||||||
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
|
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
|
||||||
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
|
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
|
||||||
|
|
||||||
@@ -313,6 +317,7 @@ public class SystemRouter {
|
|||||||
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
|
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
|
||||||
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
|
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
|
||||||
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
|
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
|
||||||
|
.POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage)
|
||||||
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
|
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
|
||||||
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
|
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
|
||||||
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
|
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
|
||||||
|
|||||||
+19
-7
@@ -22,7 +22,6 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
|
|||||||
private final MemberStoredCardRepository memberStoredCardRepository;
|
private final MemberStoredCardRepository memberStoredCardRepository;
|
||||||
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
||||||
private final IMemberCardService memberCardService;
|
private final IMemberCardService memberCardService;
|
||||||
private Long memberIdGlob;
|
|
||||||
|
|
||||||
public PaymentNotifyServiceImpl(PaymentOrderRepository paymentOrderRepository,
|
public PaymentNotifyServiceImpl(PaymentOrderRepository paymentOrderRepository,
|
||||||
MemberStoredCardRepository memberStoredCardRepository,
|
MemberStoredCardRepository memberStoredCardRepository,
|
||||||
@@ -35,8 +34,7 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> notifyPaymentStatus(String orderNo, String status,Long memberId) {
|
public Mono<Void> notifyPaymentStatus(String orderNo, String status, Long memberId) {
|
||||||
memberIdGlob = memberId;
|
|
||||||
return paymentOrderRepository.findByOrderNo(orderNo)
|
return paymentOrderRepository.findByOrderNo(orderNo)
|
||||||
.flatMap(order -> {
|
.flatMap(order -> {
|
||||||
log.info("[PaymentNotify] 支付通知: orderNo={}, status={}, orderType={}", orderNo, status, order.getOrderType());
|
log.info("[PaymentNotify] 支付通知: orderNo={}, status={}, orderType={}", orderNo, status, order.getOrderType());
|
||||||
@@ -114,19 +112,33 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
|
|||||||
final Long finalMemberCardId = memberCardId;
|
final Long finalMemberCardId = memberCardId;
|
||||||
final int finalCount = count;
|
final int finalCount = count;
|
||||||
final Long sourceOrderId = order.getId();
|
final Long sourceOrderId = order.getId();
|
||||||
|
final Long orderMemberId = order.getMemberId();
|
||||||
|
|
||||||
log.info("[PaymentNotify] 处理会员卡购买: orderNo={}, memberId={}, memberCardId={}, count={}",
|
log.info("[PaymentNotify] 处理会员卡购买: orderNo={}, memberId={}, memberCardId={}, count={}",
|
||||||
order.getOrderNo(), memberIdGlob, finalMemberCardId, finalCount);
|
order.getOrderNo(), orderMemberId, finalMemberCardId, finalCount);
|
||||||
|
|
||||||
return memberCardService.purchaseCard(memberIdGlob, finalMemberCardId, sourceOrderId)
|
// 幂等性检查:同一笔支付订单只创建一次会员卡记录
|
||||||
|
return memberCardService.purchaseCard(orderMemberId, finalMemberCardId, sourceOrderId)
|
||||||
.doOnSuccess(record -> log.info("[PaymentNotify] 会员卡购买完成: orderNo={}, memberId={}, memberCardRecordId={}",
|
.doOnSuccess(record -> log.info("[PaymentNotify] 会员卡购买完成: orderNo={}, memberId={}, memberCardRecordId={}",
|
||||||
order.getOrderNo(), memberIdGlob, record.getMemberCardRecordId()))
|
order.getOrderNo(), orderMemberId, record.getMemberCardRecordId()))
|
||||||
.doOnError(e -> log.error("[PaymentNotify] 会员卡购买异常, orderNo={}", order.getOrderNo(), e))
|
.doOnError(e -> log.error("[PaymentNotify] 会员卡购买异常, orderNo={}", order.getOrderNo(), e))
|
||||||
.onErrorResume(e -> Mono.empty())
|
.onErrorResume(e -> Mono.empty())
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> handleStoredCardRecharge(PaymentOrder order) {
|
private Mono<Void> handleStoredCardRecharge(PaymentOrder order) {
|
||||||
|
String orderNo = order.getOrderNo();
|
||||||
|
|
||||||
|
return memberStoredCardRechargeRepository.findByOrderNo(orderNo)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
log.info("[PaymentNotify] 充值记录已存在,跳过重复处理, orderNo={}, status={}", orderNo, existing.getStatus());
|
||||||
|
return Mono.<Void>empty();
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> doHandleStoredCardRecharge(order)))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> doHandleStoredCardRecharge(PaymentOrder order) {
|
||||||
String remark = order.getRemark();
|
String remark = order.getRemark();
|
||||||
log.info("[PaymentNotify] 开始处理储值卡充值, orderNo={}, memberId={}, orderType={}, remark={}",
|
log.info("[PaymentNotify] 开始处理储值卡充值, orderNo={}, memberId={}, orderType={}, remark={}",
|
||||||
order.getOrderNo(), order.getMemberId(), order.getOrderType(), remark);
|
order.getOrderNo(), order.getMemberId(), order.getOrderType(), remark);
|
||||||
@@ -166,7 +178,7 @@ public class PaymentNotifyServiceImpl implements PaymentNotifyService {
|
|||||||
final BigDecimal finalRechargeAmount = rechargeAmount;
|
final BigDecimal finalRechargeAmount = rechargeAmount;
|
||||||
final BigDecimal finalBonus = bonus != null ? bonus : BigDecimal.ZERO;
|
final BigDecimal finalBonus = bonus != null ? bonus : BigDecimal.ZERO;
|
||||||
final BigDecimal totalAddAmount = finalRechargeAmount.add(finalBonus);
|
final BigDecimal totalAddAmount = finalRechargeAmount.add(finalBonus);
|
||||||
Long memberId = memberIdGlob;
|
Long memberId = order.getMemberId();
|
||||||
final String orderNo = order.getOrderNo();
|
final String orderNo = order.getOrderNo();
|
||||||
final BigDecimal payAmount = order.getTransAmt();
|
final BigDecimal payAmount = order.getTransAmt();
|
||||||
final LocalDateTime now = LocalDateTime.now();
|
final LocalDateTime now = LocalDateTime.now();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ spring:
|
|||||||
cache:
|
cache:
|
||||||
type: none
|
type: none
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://localhost:5432/manage_system
|
url: r2dbc:postgresql://localhost:55432/manage_system
|
||||||
username: postgres
|
username: novalon
|
||||||
password: 123456
|
password: novalon123
|
||||||
pool:
|
pool:
|
||||||
initial-size: 5
|
initial-size: 5
|
||||||
max-size: 20
|
max-size: 20
|
||||||
@@ -12,10 +12,10 @@ spring:
|
|||||||
max-life-time: 30m
|
max-life-time: 30m
|
||||||
acquire-timeout: 3s
|
acquire-timeout: 3s
|
||||||
flyway:
|
flyway:
|
||||||
url: jdbc:postgresql://localhost:5432/manage_system
|
url: jdbc:postgresql://localhost:55432/manage_system
|
||||||
user: postgres
|
user: novalon
|
||||||
password: 123456
|
password: novalon123
|
||||||
enabled: false
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
validate-on-migrate: false
|
validate-on-migrate: false
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spring:
|
|||||||
exclude:
|
exclude:
|
||||||
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system}
|
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
||||||
username: ${DB_USERNAME:postgres}
|
username: ${DB_USERNAME:novalon}
|
||||||
password: ${DB_PASSWORD:123456}
|
password: ${DB_PASSWORD:novalon123}
|
||||||
pool:
|
pool:
|
||||||
initial-size: 10
|
initial-size: 10
|
||||||
max-size: 50
|
max-size: 50
|
||||||
@@ -25,12 +25,12 @@ spring:
|
|||||||
max-life-time: 1h
|
max-life-time: 1h
|
||||||
acquire-timeout: 5s
|
acquire-timeout: 5s
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:manage_system}
|
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:55432}/${DB_NAME:manage_system}
|
||||||
username: ${DB_USERNAME:postgres}
|
username: ${DB_USERNAME:novalon}
|
||||||
password: ${DB_PASSWORD:123456}
|
password: ${DB_PASSWORD:novalon123}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
flyway:
|
flyway:
|
||||||
enabled: false
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
baseline-version: 0
|
baseline-version: 0
|
||||||
|
|||||||
+31
-3
@@ -1,5 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.common.config;
|
package cn.novalon.gym.manage.common.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
|
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
|
||||||
@@ -8,6 +12,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali
|
|||||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 配置类(响应式版本)
|
* Redis 配置类(响应式版本)
|
||||||
*
|
*
|
||||||
@@ -17,20 +24,41 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class RedisConfig {
|
public class RedisConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建支持 Java 8 时间类型与类型信息的 ObjectMapper,用于 Redis JSON 序列化
|
||||||
|
*/
|
||||||
|
private ObjectMapper createRedisObjectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||||
|
javaTimeModule.addSerializer(LocalDateTime.class,
|
||||||
|
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||||
|
mapper.registerModule(javaTimeModule);
|
||||||
|
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
// 启用默认类型信息,确保反序列化时能还原为原始 Java 类型
|
||||||
|
mapper.activateDefaultTyping(
|
||||||
|
mapper.getPolymorphicTypeValidator(),
|
||||||
|
ObjectMapper.DefaultTyping.NON_FINAL
|
||||||
|
);
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配置 ReactiveRedisTemplate
|
* 配置 ReactiveRedisTemplate
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
|
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
|
||||||
ReactiveRedisConnectionFactory connectionFactory) {
|
ReactiveRedisConnectionFactory connectionFactory) {
|
||||||
|
|
||||||
|
GenericJackson2JsonRedisSerializer jsonSerializer =
|
||||||
|
new GenericJackson2JsonRedisSerializer(createRedisObjectMapper());
|
||||||
|
|
||||||
// 配置序列化上下文
|
// 配置序列化上下文
|
||||||
RedisSerializationContext<String, Object> serializationContext =
|
RedisSerializationContext<String, Object> serializationContext =
|
||||||
RedisSerializationContext.<String, Object>newSerializationContext()
|
RedisSerializationContext.<String, Object>newSerializationContext()
|
||||||
.key(StringRedisSerializer.UTF_8)
|
.key(StringRedisSerializer.UTF_8)
|
||||||
.value(new GenericJackson2JsonRedisSerializer())
|
.value(jsonSerializer)
|
||||||
.hashKey(StringRedisSerializer.UTF_8)
|
.hashKey(StringRedisSerializer.UTF_8)
|
||||||
.hashValue(new GenericJackson2JsonRedisSerializer())
|
.hashValue(jsonSerializer)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
|
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
|
||||||
|
|||||||
+18
@@ -12,6 +12,8 @@ public class PageRequest {
|
|||||||
private String sort = "id";
|
private String sort = "id";
|
||||||
private String order = "asc";
|
private String order = "asc";
|
||||||
private String keyword;
|
private String keyword;
|
||||||
|
private String status;
|
||||||
|
private String category;
|
||||||
|
|
||||||
public int getPage() {
|
public int getPage() {
|
||||||
return page;
|
return page;
|
||||||
@@ -52,4 +54,20 @@ public class PageRequest {
|
|||||||
public void setKeyword(String keyword) {
|
public void setKeyword(String keyword) {
|
||||||
this.keyword = keyword;
|
this.keyword = keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategory(String category) {
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.common.util;
|
package cn.novalon.gym.manage.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -13,6 +14,7 @@ import java.time.Duration;
|
|||||||
* @author liwentao
|
* @author liwentao
|
||||||
* @date 2026/5/15
|
* @date 2026/5/15
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class RedisUtil {
|
public class RedisUtil {
|
||||||
|
|
||||||
@@ -34,12 +36,19 @@ public class RedisUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取值
|
* 获取值(带类型转换)
|
||||||
|
* filter+cast 替代 map 避免 NPE,onErrorResume 兼容旧缓存格式反序列化失败
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> Mono<T> get(String key, Class<T> clazz) {
|
public <T> Mono<T> get(String key, Class<T> clazz) {
|
||||||
return reactiveRedisTemplate.opsForValue().get(key)
|
return reactiveRedisTemplate.opsForValue().get(key)
|
||||||
.map(obj -> clazz.isInstance(obj) ? (T) obj : null);
|
.filter(clazz::isInstance)
|
||||||
|
.cast(clazz)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
// 旧缓存数据格式不兼容时静默跳过,后续逻辑会 fallback 到 DB 查询
|
||||||
|
log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+93
-93
@@ -1,7 +1,7 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- V13: 整合所有测试数据(基于 2026-06-16 17:13)
|
-- V13: 整合所有测试数据(基于 2026-06-29 17:13)
|
||||||
-- 说明: 系统初始数据由 V2 处理,本文件仅包含业务测试数据
|
-- 说明: 系统初始数据由 V2 处理,本文件仅包含业务测试数据
|
||||||
-- 所有时间相关字段已更新为基于 2026-06-16
|
-- 所有时间相关字段已更新为基于 2026-06-29
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -84,38 +84,38 @@ INSERT INTO member_card (member_card_name, member_card_type, member_card_price,
|
|||||||
|
|
||||||
-- E1: 数据统计测试会员(V14,显式 ID 1001-1012)
|
-- E1: 数据统计测试会员(V14,显式 ID 1001-1012)
|
||||||
INSERT INTO member_user (id, member_no, nickname, phone, created_at, updated_at, is_deleted) VALUES
|
INSERT INTO member_user (id, member_no, nickname, phone, created_at, updated_at, is_deleted) VALUES
|
||||||
(1001, 'M20260616001', '张三', '13800138001', '2026-06-16 08:00:00', '2026-06-16 08:00:00', false),
|
(1001, 'M20260629001', '张三', '13800138001', '2026-06-29 08:00:00', '2026-06-29 08:00:00', false),
|
||||||
(1002, 'M20260616002', '李四', '13800138002', '2026-06-16 09:00:00', '2026-06-16 09:00:00', false),
|
(1002, 'M20260629002', '李四', '13800138002', '2026-06-29 09:00:00', '2026-06-29 09:00:00', false),
|
||||||
(1003, 'M20260615003', '王五', '13800138003', '2026-06-15 10:00:00', '2026-06-15 10:00:00', false),
|
(1003, 'M20260628003', '王五', '13800138003', '2026-06-28 10:00:00', '2026-06-28 10:00:00', false),
|
||||||
(1004, 'M20260614004', '赵六', '13800138004', '2026-06-14 11:00:00', '2026-06-14 11:00:00', false),
|
(1004, 'M20260627004', '赵六', '13800138004', '2026-06-27 11:00:00', '2026-06-27 11:00:00', false),
|
||||||
(1005, 'M20260616005', '钱七', '13800138005', '2026-06-16 08:00:00', '2026-06-16 08:00:00', false),
|
(1005, 'M20260629005', '钱七', '13800138005', '2026-06-29 08:00:00', '2026-06-29 08:00:00', false),
|
||||||
(1006, 'M20260616006', '孙八', '13800138006', '2026-06-16 09:00:00', '2026-06-16 09:00:00', false),
|
(1006, 'M20260629006', '孙八', '13800138006', '2026-06-29 09:00:00', '2026-06-29 09:00:00', false),
|
||||||
(1007, 'M20260616007', '周九', '13800138007', '2026-06-16 10:00:00', '2026-06-16 10:00:00', false),
|
(1007, 'M20260629007', '周九', '13800138007', '2026-06-29 10:00:00', '2026-06-29 10:00:00', false),
|
||||||
(1008, 'M20260613008', '吴十', '13800138008', '2026-06-13 14:00:00', '2026-06-13 14:00:00', false),
|
(1008, 'M20260626008', '吴十', '13800138008', '2026-06-26 14:00:00', '2026-06-26 14:00:00', false),
|
||||||
(1009, 'M20260612009', '郑十一', '13800138009', '2026-06-12 15:00:00', '2026-06-12 15:00:00', false),
|
(1009, 'M20260625009', '郑十一', '13800138009', '2026-06-25 15:00:00', '2026-06-25 15:00:00', false),
|
||||||
(1010, 'M20260611010', '王十二', '13800138010', '2026-06-11 16:00:00', '2026-06-11 16:00:00', false),
|
(1010, 'M20260624010', '王十二', '13800138010', '2026-06-24 16:00:00', '2026-06-24 16:00:00', false),
|
||||||
(1011, 'M20260610011', '陈十三', '13800138011', '2026-06-10 17:00:00', '2026-06-10 17:00:00', false),
|
(1011, 'M20260623011', '陈十三', '13800138011', '2026-06-23 17:00:00', '2026-06-23 17:00:00', false),
|
||||||
(1012, 'M20260609012', '刘十四', '13800138012', '2026-06-09 18:00:00', '2026-06-09 18:00:00', false);
|
(1012, 'M20260622012', '刘十四', '13800138012', '2026-06-22 18:00:00', '2026-06-22 18:00:00', false);
|
||||||
|
|
||||||
-- E2: 预约场景测试会员(V10: A-G)
|
-- E2: 预约场景测试会员(V10: A-G)
|
||||||
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
||||||
('MEM_TEST_A', '用户A_无卡新用户', '13800001001', 1, false, '2026-06-16 10:00:00', '2026-06-16 10:00:00'),
|
('MEM_TEST_A', '用户A_无卡新用户', '13800001001', 1, false, '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('MEM_TEST_B', '用户B_过期时长卡', '13800001002', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_B', '用户B_过期时长卡', '13800001002', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_C', '会员C_有效时长卡', '13800001003', 2, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_C', '会员C_有效时长卡', '13800001003', 2, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_D', '会员D_次数用尽', '13800001004', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_D', '会员D_次数用尽', '13800001004', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_E', '会员E_次数充足', '13800001005', 2, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_E', '会员E_次数充足', '13800001005', 2, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_F', '会员F_储值卡余额不足', '13800001006', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_F', '会员F_储值卡余额不足', '13800001006', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_G', '会员G_储值卡余额充足', '13800001007', 2, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00');
|
('MEM_TEST_G', '会员G_储值卡余额充足', '13800001007', 2, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00');
|
||||||
|
|
||||||
-- E3: 取消预约测试会员(V11: 张三/李四/王五)
|
-- E3: 取消预约测试会员(V11: 张三/李四/王五)
|
||||||
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
||||||
('MEM_TEST_ZHANG', '张三_时长卡用户', '13800002001', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_ZHANG', '张三_时长卡用户', '13800002001', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_LI', '李四_次数卡用户', '13800002002', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00'),
|
('MEM_TEST_LI', '李四_次数卡用户', '13800002002', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00'),
|
||||||
('MEM_TEST_WANG', '王五_储值卡用户', '13800002003', 2, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00');
|
('MEM_TEST_WANG', '王五_储值卡用户', '13800002003', 2, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00');
|
||||||
|
|
||||||
-- E4: 时间冲突测试会员(V12)
|
-- E4: 时间冲突测试会员(V12)
|
||||||
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
||||||
('MEM_TIME_CONFLICT', '时间冲突测试会员', '13800009999', 1, false, '2026-06-15 10:00:00', '2026-06-15 10:00:00');
|
('MEM_TIME_CONFLICT', '时间冲突测试会员', '13800009999', 1, false, '2026-06-28 10:00:00', '2026-06-28 10:00:00');
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section F: 会员卡记录
|
-- Section F: 会员卡记录
|
||||||
@@ -123,67 +123,67 @@ INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created
|
|||||||
|
|
||||||
-- F1: V10 预约场景 - 6条会员卡记录
|
-- F1: V10 预约场景 - 6条会员卡记录
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_B'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'EXPIRED', 0, 0.00, '2026-06-15 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_B'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'EXPIRED', 0, 0.00, '2026-06-28 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_C'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'ACTIVE', 0, 0.00, '2026-07-16 23:59:59', '2026-06-16 10:00:00', 0, '{}', '2026-06-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_C'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'ACTIVE', 0, 0.00, '2026-07-29 23:59:59', '2026-06-29 10:00:00', 0, '{}', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_D'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 0, 0.00, '2026-12-31 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_D'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 0, 0.00, '2026-12-31 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_E'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 5, 0.00, '2026-12-31 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_E'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 5, 0.00, '2026-12-31 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_F'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 30.00, '2027-06-16 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_F'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 30.00, '2027-06-29 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_G'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 200.00, '2027-06-16 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_G'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 200.00, '2027-06-29 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- F2: V11 取消预约场景 - 3条
|
-- F2: V11 取消预约场景 - 3条
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'ACTIVE', 0, 0.00, '2026-07-16 23:59:59', '2026-06-16 10:00:00', 0, '{}', '2026-06-16 10:00:00', '2026-06-16 10:00:00'),
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'ACTIVE', 0, 0.00, '2026-07-29 23:59:59', '2026-06-29 10:00:00', 0, '{}', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 5, 0.00, '2026-12-31 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00'),
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 5, 0.00, '2026-12-31 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 200.00, '2027-06-16 23:59:59', '2026-05-16 10:00:00', 0, '{}', '2026-05-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 200.00, '2027-06-29 23:59:59', '2026-05-29 10:00:00', 0, '{}', '2026-05-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- F3: V12 时间冲突场景 - 1条
|
-- F3: V12 时间冲突场景 - 1条
|
||||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TIME_CONFLICT'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 10, 0.00, '2026-12-31 23:59:59', '2026-06-16 10:00:00', 0, '{}', '2026-06-16 10:00:00', '2026-06-16 10:00:00');
|
((SELECT id FROM member_user WHERE member_no = 'MEM_TIME_CONFLICT'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 10, 0.00, '2026-12-31 23:59:59', '2026-06-29 10:00:00', 0, '{}', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section G: 团课课程(来源 V7 / V10 / V11 / V12 / V14,基于 2026-06-24)
|
-- Section G: 团课课程(来源 V7 / V10 / V11 / V12 / V14,基于 2026-06-29)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- G1: V7 - 7种场景团课
|
-- G1: V7 - 7种场景团课
|
||||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||||
('极速燃脂单车', 104, 2, '2026-06-25 16:45:00', '2026-06-25 20:20:00', 25, 0, 0, '单车房', '/images/spinning.jpg', '跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感。', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
('极速燃脂单车', 104, 2, '2026-06-30 16:45:00', '2026-06-30 20:20:00', 25, 0, 0, '单车房', '/images/spinning.jpg', '跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感。', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('清晨流瑜伽', 101, 1, '2026-06-28 09:00:00', '2026-06-28 10:30:00', 15, 5, 0, 'A座3楼瑜伽教室', '/images/yoga_flow.jpg', '适合有一定基础的学员,通过流畅的体式连接呼吸。', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
('清晨流瑜伽', 101, 1, '2026-07-03 09:00:00', '2026-07-03 10:30:00', 15, 5, 0, 'A座3楼瑜伽教室', '/images/yoga_flow.jpg', '适合有一定基础的学员,通过流畅的体式连接呼吸。', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('燃脂搏击', 102, 2, '2026-06-26 18:30:00', '2026-06-26 19:30:00', 20, 20, 0, '综合训练区', '/images/kickboxing.jpg', '高强度间歇训练,名额已满。', 50.00, 'coach_zhang', '2026-06-24 14:30:00', '2026-06-24 14:30:00'),
|
('燃脂搏击', 102, 2, '2026-07-01 18:30:00', '2026-07-01 19:30:00', 20, 20, 0, '综合训练区', '/images/kickboxing.jpg', '高强度间歇训练,名额已满。', 50.00, 'coach_zhang', '2026-06-29 14:30:00', '2026-06-29 14:30:00'),
|
||||||
('哈他瑜伽', 101, 1, '2026-06-24 17:45:00', '2026-06-24 18:50:00', 12, 3, 0, '瑜伽教室B', '/images/hatha_yoga.jpg', '基础哈他瑜伽,距开始不足30分钟,已停止预约。', 50.00, 'coach_li', '2026-06-24 08:00:00', '2026-06-24 08:00:00'),
|
('哈他瑜伽', 101, 1, '2026-06-29 17:45:00', '2026-06-29 18:50:00', 12, 3, 0, '瑜伽教室B', '/images/hatha_yoga.jpg', '基础哈他瑜伽,距开始不足30分钟,已停止预约。', 50.00, 'coach_li', '2026-06-29 08:00:00', '2026-06-29 08:00:00'),
|
||||||
('周末冥想修复', 101, 1, '2026-07-06 15:00:00', '2026-07-06 16:00:00', 12, 3, 1, '冥想室', '/images/meditation.jpg', '通过呼吸和正念冥想,该课程已被取消。', 50.00, 'coach_wang', '2026-06-24 08:00:00', '2026-06-24 08:00:00'),
|
('周末冥想修复', 101, 1, '2026-07-11 15:00:00', '2026-07-11 16:00:00', 12, 3, 1, '冥想室', '/images/meditation.jpg', '通过呼吸和正念冥想,该课程已被取消。', 50.00, 'coach_wang', '2026-06-29 08:00:00', '2026-06-29 08:00:00'),
|
||||||
('蜜桃臀塑造', 103, 3, '2026-06-18 19:00:00', '2026-06-18 20:00:00', 10, 8, 2, '私教专区', '/images/glute.jpg', '针对性训练臀部肌肉群,课程已结束。', 50.00, 'coach_li', '2026-06-18 09:15:00', '2026-06-18 09:15:00'),
|
('蜜桃臀塑造', 103, 3, '2026-06-23 19:00:00', '2026-06-23 20:00:00', 10, 8, 2, '私教专区', '/images/glute.jpg', '针对性训练臀部肌肉群,课程已结束。', 50.00, 'coach_li', '2026-06-23 09:15:00', '2026-06-23 09:15:00'),
|
||||||
('午间冥想放松', 101, 1, '2026-06-20 12:00:00', '2026-06-20 13:00:00', 15, 6, 2, '冥想室', '/images/meditation_noon.jpg', '午间冥想课程,已结束。', 50.00, 'admin', '2026-06-20 09:00:00', '2026-06-20 09:00:00');
|
('午间冥想放松', 101, 1, '2026-06-25 12:00:00', '2026-06-25 13:00:00', 15, 6, 2, '冥想室', '/images/meditation_noon.jpg', '午间冥想课程,已结束。', 50.00, 'admin', '2026-06-25 09:00:00', '2026-06-25 09:00:00');
|
||||||
|
|
||||||
-- G2: V10 预约场景(已取消次数卡支付,仅保留储值卡)
|
-- G2: V10 预约场景(已取消次数卡支付,仅保留储值卡)
|
||||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||||
('燃脂搏击_储值测试', 102, 2, '2026-06-26 19:30:00', '2026-06-26 20:30:00', 20, 0, 0, '综合训练区', '消耗储值50元', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
('燃脂搏击_储值测试', 102, 2, '2026-07-01 19:30:00', '2026-07-01 20:30:00', 20, 0, 0, '综合训练区', '消耗储值50元', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('高端普拉提_储值卡课程', 103, 1, '2026-06-27 19:00:00', '2026-06-27 20:00:00', 15, 0, 0, '普拉提教室', '消耗储值20元', 20.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00');
|
('高端普拉提_储值卡课程', 103, 1, '2026-07-02 19:00:00', '2026-07-02 20:00:00', 15, 0, 0, '普拉提教室', '消耗储值20元', 20.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- G3: V11 取消预约场景
|
-- G3: V11 取消预约场景
|
||||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||||
('晚间瑜伽_取消测试', 101, 1, '2026-06-27 19:00:00', '2026-06-27 20:00:00', 20, 0, 0, '瑜伽教室', '用于测试取消预约功能', 30.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00');
|
('晚间瑜伽_取消测试', 101, 1, '2026-07-02 19:00:00', '2026-07-02 20:00:00', 20, 0, 0, '瑜伽教室', '用于测试取消预约功能', 30.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- G4: V12 时间冲突场景
|
-- G4: V12 时间冲突场景
|
||||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||||
('时间冲突测试_A_13点-15点', 102, 2, '2026-06-26 13:00:00', '2026-06-26 15:00:00', 20, 0, 0, '综合训练区', '测试用团课A', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
('时间冲突测试_A_13点-15点', 102, 2, '2026-07-01 13:00:00', '2026-07-01 15:00:00', 20, 0, 0, '综合训练区', '测试用团课A', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('时间冲突测试_B_14点-16点', 103, 1, '2026-06-26 14:00:00', '2026-06-26 16:00:00', 15, 0, 0, '普拉提教室', '与团课A时间重叠', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
('时间冲突测试_B_14点-16点', 103, 1, '2026-07-01 14:00:00', '2026-07-01 16:00:00', 15, 0, 0, '普拉提教室', '与团课A时间重叠', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
('时间冲突测试_C_10点-12点', 101, 1, '2026-06-26 10:00:00', '2026-06-26 12:00:00', 15, 0, 0, '瑜伽教室', '与A/B不冲突', 50.00, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00');
|
('时间冲突测试_C_10点-12点', 101, 1, '2026-07-01 10:00:00', '2026-07-01 12:00:00', 15, 0, 0, '瑜伽教室', '与A/B不冲突', 50.00, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- G5: V14 数据统计场景(今天 2026-06-24)
|
-- G5: V14 数据统计场景(今天 2026-06-29)
|
||||||
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, created_at, updated_at) VALUES
|
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, created_at, updated_at) VALUES
|
||||||
(3001, '瑜伽入门', 1, 1, '2026-06-24 08:00:00', '2026-06-24 09:00:00', 20, 15, 0, '健身房A区', 'https://example.com/yoga.jpg', '适合初学者的瑜伽课程', 50.00, '2026-06-24 10:00:00', '2026-06-24 10:00:00'),
|
(3001, '瑜伽入门', 1, 1, '2026-06-29 08:00:00', '2026-06-29 09:00:00', 20, 15, 0, '健身房A区', 'https://example.com/yoga.jpg', '适合初学者的瑜伽课程', 50.00, '2026-06-29 10:00:00', '2026-06-29 10:00:00'),
|
||||||
(3002, '动感单车', 2, 2, '2026-06-24 09:30:00', '2026-06-24 10:30:00', 25, 20, 0, '健身房B区', 'https://example.com/spinning.jpg', '高强度有氧运动', 50.00, '2026-06-24 11:00:00', '2026-06-24 11:00:00'),
|
(3002, '动感单车', 2, 2, '2026-06-29 09:30:00', '2026-06-29 10:30:00', 25, 20, 0, '健身房B区', 'https://example.com/spinning.jpg', '高强度有氧运动', 50.00, '2026-06-29 11:00:00', '2026-06-29 11:00:00'),
|
||||||
(3003, '普拉提', 3, 1, '2026-06-24 14:00:00', '2026-06-24 15:00:00', 15, 10, 0, '健身房C区', 'https://example.com/pilates.jpg', '核心力量训练', 50.00, '2026-06-24 12:00:00', '2026-06-24 12:00:00');
|
(3003, '普拉提', 3, 1, '2026-06-29 14:00:00', '2026-06-29 15:00:00', 15, 10, 0, '健身房C区', 'https://example.com/pilates.jpg', '核心力量训练', 50.00, '2026-06-29 12:00:00', '2026-06-29 12:00:00');
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section H: 团课预约记录
|
-- Section H: 团课预约记录
|
||||||
@@ -191,26 +191,26 @@ INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, en
|
|||||||
|
|
||||||
-- H1: V11 取消预约测试
|
-- H1: V11 取消预约测试
|
||||||
INSERT INTO group_course_booking (course_id, member_id, member_card_id, booking_time, status, course_name, course_start_time, course_end_time, location, created_at, updated_at) VALUES
|
INSERT INTO group_course_booking (course_id, member_id, member_card_id, booking_time, status, course_name, course_start_time, course_end_time, location, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG')), '2026-06-24 11:00:00', '0', '晚间瑜伽_取消测试', '2026-06-27 19:00:00', '2026-06-27 20:00:00', '瑜伽教室', '2026-06-24 11:00:00', '2026-06-24 11:00:00'),
|
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_ZHANG')), '2026-06-29 11:00:00', '0', '晚间瑜伽_取消测试', '2026-07-02 19:00:00', '2026-07-02 20:00:00', '瑜伽教室', '2026-06-29 11:00:00', '2026-06-29 11:00:00'),
|
||||||
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI')), '2026-06-24 11:30:00', '0', '晚间瑜伽_取消测试', '2026-06-27 19:00:00', '2026-06-27 20:00:00', '瑜伽教室', '2026-06-24 11:30:00', '2026-06-24 11:30:00'),
|
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_LI')), '2026-06-29 11:30:00', '0', '晚间瑜伽_取消测试', '2026-07-02 19:00:00', '2026-07-02 20:00:00', '瑜伽教室', '2026-06-29 11:30:00', '2026-06-29 11:30:00'),
|
||||||
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG')), '2026-06-24 12:00:00', '0', '晚间瑜伽_取消测试', '2026-06-27 19:00:00', '2026-06-27 20:00:00', '瑜伽教室', '2026-06-24 12:00:00', '2026-06-24 12:00:00');
|
((SELECT id FROM group_course WHERE course_name = '晚间瑜伽_取消测试'), (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG'), (SELECT id FROM member_card_record WHERE member_id = (SELECT id FROM member_user WHERE member_no = 'MEM_TEST_WANG')), '2026-06-29 12:00:00', '0', '晚间瑜伽_取消测试', '2026-07-02 19:00:00', '2026-07-02 20:00:00', '瑜伽教室', '2026-06-29 12:00:00', '2026-06-29 12:00:00');
|
||||||
|
|
||||||
UPDATE group_course SET current_members = current_members + 3 WHERE course_name = '晚间瑜伽_取消测试';
|
UPDATE group_course SET current_members = current_members + 3 WHERE course_name = '晚间瑜伽_取消测试';
|
||||||
|
|
||||||
-- H2: V14 数据统计预约 - 今天(6月24日)
|
-- H2: V14 数据统计预约 - 今天(6月24日)
|
||||||
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
||||||
(4001, 1001, 1, 3001, '2026-06-24 08:00:00', '2', '2026-06-23 20:00:00', '2026-06-24 08:30:00'),
|
(4001, 1001, 1, 3001, '2026-06-29 08:00:00', '2', '2026-06-23 20:00:00', '2026-06-29 08:30:00'),
|
||||||
(4002, 1002, 2, 3001, '2026-06-24 08:00:00', '2', '2026-06-23 21:00:00', '2026-06-24 08:30:00'),
|
(4002, 1002, 2, 3001, '2026-06-29 08:00:00', '2', '2026-06-23 21:00:00', '2026-06-29 08:30:00'),
|
||||||
(4003, 1003, 3, 3001, '2026-06-24 08:00:00', '3', '2026-06-23 22:00:00', '2026-06-24 09:00:00'),
|
(4003, 1003, 3, 3001, '2026-06-29 08:00:00', '3', '2026-06-23 22:00:00', '2026-06-29 09:00:00'),
|
||||||
(4004, 1004, 4, 3002, '2026-06-24 09:30:00', '2', '2026-06-23 19:00:00', '2026-06-24 09:30:00'),
|
(4004, 1004, 4, 3002, '2026-06-29 09:30:00', '2', '2026-06-23 19:00:00', '2026-06-29 09:30:00'),
|
||||||
(4005, 1005, 5, 3002, '2026-06-24 09:30:00', '1', '2026-06-23 20:30:00', '2026-06-24 09:00:00'),
|
(4005, 1005, 5, 3002, '2026-06-29 09:30:00', '1', '2026-06-23 20:30:00', '2026-06-29 09:00:00'),
|
||||||
(4006, 1006, 6, 3002, '2026-06-24 09:30:00', '2', '2026-06-23 21:30:00', '2026-06-24 09:30:00'),
|
(4006, 1006, 6, 3002, '2026-06-29 09:30:00', '2', '2026-06-23 21:30:00', '2026-06-29 09:30:00'),
|
||||||
(4007, 1007, 7, 3003, '2026-06-24 14:00:00', '2', '2026-06-23 22:30:00', '2026-06-24 14:00:00'),
|
(4007, 1007, 7, 3003, '2026-06-29 14:00:00', '2', '2026-06-23 22:30:00', '2026-06-29 14:00:00'),
|
||||||
(4008, 1008, 8, 3003, '2026-06-24 14:00:00', '3', '2026-06-24 08:00:00', '2026-06-24 14:00:00'),
|
(4008, 1008, 8, 3003, '2026-06-29 14:00:00', '3', '2026-06-29 08:00:00', '2026-06-29 14:00:00'),
|
||||||
(4009, 1009, 9, 3003, '2026-06-24 14:00:00', '2', '2026-06-24 09:00:00', '2026-06-24 14:00:00'),
|
(4009, 1009, 9, 3003, '2026-06-29 14:00:00', '2', '2026-06-29 09:00:00', '2026-06-29 14:00:00'),
|
||||||
(4010, 1010, 10, 3001, '2026-06-24 08:00:00', '2', '2026-06-24 07:00:00', '2026-06-24 08:00:00'),
|
(4010, 1010, 10, 3001, '2026-06-29 08:00:00', '2', '2026-06-29 07:00:00', '2026-06-29 08:00:00'),
|
||||||
(4011, 1011, 11, 3002, '2026-06-24 09:30:00', '1', '2026-06-24 08:30:00', '2026-06-24 09:00:00'),
|
(4011, 1011, 11, 3002, '2026-06-29 09:30:00', '1', '2026-06-29 08:30:00', '2026-06-29 09:00:00'),
|
||||||
(4012, 1012, 12, 3003, '2026-06-24 14:00:00', '2', '2026-06-24 10:00:00', '2026-06-24 14:00:00');
|
(4012, 1012, 12, 3003, '2026-06-29 14:00:00', '2', '2026-06-29 10:00:00', '2026-06-29 14:00:00');
|
||||||
|
|
||||||
-- H3: V14 数据统计预约 - 昨天(6月23日)
|
-- H3: V14 数据统计预约 - 昨天(6月23日)
|
||||||
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, booking_time, status, created_at, updated_at) VALUES
|
||||||
@@ -229,22 +229,22 @@ INSERT INTO group_course_booking (id, member_id, member_card_id, course_id, book
|
|||||||
(4022, 1004, 4, 3003, '2026-06-22 14:00:00', '3', '2026-06-21 19:00:00', '2026-06-22 14:00:00');
|
(4022, 1004, 4, 3003, '2026-06-22 14:00:00', '3', '2026-06-21 19:00:00', '2026-06-22 14:00:00');
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section I: 签到记录(来源 V14,基于 2026-06-24)
|
-- Section I: 签到记录(来源 V14,基于 2026-06-29)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
||||||
(2001, 1001, '2026-06-24 08:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2001, 1001, '2026-06-29 08:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2002, 1002, '2026-06-24 08:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
(2002, 1002, '2026-06-29 08:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||||
(2003, 1003, '2026-06-24 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2003, 1003, '2026-06-29 08:30:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2004, 1004, '2026-06-24 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2004, 1004, '2026-06-29 09:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2005, 1005, '2026-06-24 09:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
(2005, 1005, '2026-06-29 09:15:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||||
(2006, 1006, '2026-06-24 09:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2006, 1006, '2026-06-29 09:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2007, 1007, '2026-06-24 10:00:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2007, 1007, '2026-06-29 10:00:00', 'FACE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2008, 1008, '2026-06-24 10:15:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2008, 1008, '2026-06-29 10:15:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2009, 1009, '2026-06-24 10:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
(2009, 1009, '2026-06-29 10:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false),
|
||||||
(2010, 1010, '2026-06-24 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2010, 1010, '2026-06-29 11:00:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
(2011, 1011, '2026-06-24 11:15:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
|
(2011, 1011, '2026-06-29 11:15:00', 'FACE', 'FAIL', 'MINI_PROGRAM', false),
|
||||||
(2012, 1012, '2026-06-24 11:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false);
|
(2012, 1012, '2026-06-29 11:30:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false);
|
||||||
|
|
||||||
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete) VALUES
|
||||||
(2013, 1001, '2026-06-23 07:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
(2013, 1001, '2026-06-23 07:30:00', 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', false),
|
||||||
@@ -264,43 +264,43 @@ INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_s
|
|||||||
(2025, 1005, '2026-06-22 09:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false);
|
(2025, 1005, '2026-06-22 09:00:00', 'MANUAL', 'SUCCESS', 'PC_BACKEND', false);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section J: 团课推荐测试数据(来源 V18,基于 2026-06-24)
|
-- Section J: 团课推荐测试数据(来源 V18,基于 2026-06-29)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '极速燃脂单车'), '本周热门推荐', '极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!', '教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著', 20, true, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00');
|
((SELECT id FROM group_course WHERE course_name = '极速燃脂单车'), '本周热门推荐', '极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!', '教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著', 20, true, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '清晨流瑜伽'), '新手友好推荐', '清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。', '适合新手入门,教练耐心指导,课程节奏适中', 15, true, 'admin', '2026-06-24 11:00:00', '2026-06-24 11:00:00');
|
((SELECT id FROM group_course WHERE course_name = '清晨流瑜伽'), '新手友好推荐', '清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。', '适合新手入门,教练耐心指导,课程节奏适中', 15, true, 'admin', '2026-06-29 11:00:00', '2026-06-29 11:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '燃脂搏击'), '高强度燃脂', '燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。', '高强度训练,适合进阶学员,快速燃脂塑形', 10, false, 'admin', '2026-06-24 12:00:00', '2026-06-24 12:00:00');
|
((SELECT id FROM group_course WHERE course_name = '燃脂搏击'), '高强度燃脂', '燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。', '高强度训练,适合进阶学员,快速燃脂塑形', 10, false, 'admin', '2026-06-29 12:00:00', '2026-06-29 12:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '哈他瑜伽'), '基础瑜伽推荐', '基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。', '零基础友好,适合所有健身水平,放松身心', 12, true, 'coach_li', '2026-06-24 13:00:00', '2026-06-24 13:00:00');
|
((SELECT id FROM group_course WHERE course_name = '哈他瑜伽'), '基础瑜伽推荐', '基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。', '零基础友好,适合所有健身水平,放松身心', 12, true, 'coach_li', '2026-06-29 13:00:00', '2026-06-29 13:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '蜜桃臀塑造'), '塑形热门课程', '蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。', '专业私教指导,动作标准,效果显著,深受女性学员喜爱', 18, true, 'coach_li', '2026-06-24 09:15:00', '2026-06-24 09:15:00');
|
((SELECT id FROM group_course WHERE course_name = '蜜桃臀塑造'), '塑形热门课程', '蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。', '专业私教指导,动作标准,效果显著,深受女性学员喜爱', 18, true, 'coach_li', '2026-06-29 09:15:00', '2026-06-29 09:15:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '午间冥想放松'), '午间放松推荐', '午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。', '适合上班族,午间放松充电,提升下午工作效率', 8, true, 'admin', '2026-06-24 09:00:00', '2026-06-24 09:00:00');
|
((SELECT id FROM group_course WHERE course_name = '午间冥想放松'), '午间放松推荐', '午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。', '适合上班族,午间放松充电,提升下午工作效率', 8, true, 'admin', '2026-06-29 09:00:00', '2026-06-29 09:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '极速燃脂单车'), '减脂首选课程', '想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。', '减脂效果最佳,课程强度适中,适合想要快速瘦身的学员', 16, true, 'coach_zhang', '2026-06-24 14:00:00', '2026-06-24 14:00:00');
|
((SELECT id FROM group_course WHERE course_name = '极速燃脂单车'), '减脂首选课程', '想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。', '减脂效果最佳,课程强度适中,适合想要快速瘦身的学员', 16, true, 'coach_zhang', '2026-06-29 14:00:00', '2026-06-29 14:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '清晨流瑜伽'), '晨练优选', '清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。', '晨练最佳选择,提升身体活力,改善精神状态', 14, true, 'coach_wang', '2026-06-24 15:00:00', '2026-06-24 15:00:00');
|
((SELECT id FROM group_course WHERE course_name = '清晨流瑜伽'), '晨练优选', '清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。', '晨练最佳选择,提升身体活力,改善精神状态', 14, true, 'coach_wang', '2026-06-29 15:00:00', '2026-06-29 15:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '哈他瑜伽'), '身心平衡推荐', '哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。', '改善身体柔韧性,增强核心力量,提升身体协调性', 11, true, 'coach_li', '2026-06-24 16:00:00', '2026-06-24 16:00:00');
|
((SELECT id FROM group_course WHERE course_name = '哈他瑜伽'), '身心平衡推荐', '哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。', '改善身体柔韧性,增强核心力量,提升身体协调性', 11, true, 'coach_li', '2026-06-29 16:00:00', '2026-06-29 16:00:00');
|
||||||
|
|
||||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||||
((SELECT id FROM group_course WHERE course_name = '午间冥想放松'), '职场减压课程', '午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。', '职场减压首选,课程时间短,效果显著', 9, false, 'admin', '2026-06-24 10:00:00', '2026-06-24 10:00:00');
|
((SELECT id FROM group_course WHERE course_name = '午间冥想放松'), '职场减压课程', '午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。', '职场减压首选,课程时间短,效果显著', 9, false, 'admin', '2026-06-29 10:00:00', '2026-06-29 10:00:00');
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Section K: 统计说明
|
-- Section K: 统计说明
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 今日(2026-06-24)统计预期:
|
-- 今日(2026-06-29)统计预期:
|
||||||
-- 新增会员: 3 (1005, 1006, 1007)
|
-- 新增会员: 3 (1005, 1006, 1007)
|
||||||
-- 签到会员: 12
|
-- 签到会员: 12
|
||||||
-- 预约总数: 12 (4001-4012),取消: 2,出席: 8,缺席: 2
|
-- 预约总数: 12 (4001-4012),取消: 2,出席: 8,缺席: 2
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V28: 新增业务模块菜单
|
||||||
|
-- 描述: 为团课管理、团课类型、团课标签、团课推荐创建菜单项
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 团课管理一级菜单(目录)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(400, '团课管理', 0, 4, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课管理子菜单(页面)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(401, '团课管理', 400, 1, 'C', 'business:groupCourse:list', 'gymCourse/course/index', 1, NOW(), NOW()),
|
||||||
|
(402, '团课类型', 400, 2, 'C', 'business:groupCourseType:list', 'gymCourse/type/index', 1, NOW(), NOW()),
|
||||||
|
(403, '类型标签', 400, 3, 'C', 'business:groupCourseLabel:list', 'gymCourse/label/index', 1, NOW(), NOW()),
|
||||||
|
(404, '团课推荐', 400, 4, 'C', 'business:groupCourseRecommend:list', 'gymCourse/recommend/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
|
||||||
|
(4011, '团课查询', 401, 1, 'F', 'business:groupCourse:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4012, '团课新增', 401, 2, 'F', 'business:groupCourse:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4013, '团课修改', 401, 3, 'F', 'business:groupCourse:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4014, '团课删除', 401, 4, 'F', 'business:groupCourse:remove', NULL, 1, NOW(), NOW()),
|
||||||
|
(4015, '团课取消', 401, 5, 'F', 'business:groupCourse:cancel', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课类型按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4021, '类型查询', 402, 1, 'F', 'business:groupCourseType:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4022, '类型新增', 402, 2, 'F', 'business:groupCourseType:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4023, '类型修改', 402, 3, 'F', 'business:groupCourseType:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4024, '类型删除', 402, 4, 'F', 'business:groupCourseType:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课标签按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4031, '标签查询', 403, 1, 'F', 'business:groupCourseLabel:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4032, '标签新增', 403, 2, 'F', 'business:groupCourseLabel:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4033, '标签修改', 403, 3, 'F', 'business:groupCourseLabel:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4034, '标签删除', 403, 4, 'F', 'business:groupCourseLabel:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课推荐按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4041, '推荐查询', 404, 1, 'F', 'business:groupCourseRecommend:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4042, '推荐新增', 404, 2, 'F', 'business:groupCourseRecommend:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4043, '推荐修改', 404, 3, 'F', 'business:groupCourseRecommend:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4044, '推荐删除', 404, 4, 'F', 'business:groupCourseRecommend:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V29: 新增会员管理菜单
|
||||||
|
-- 描述: 为会员管理功能创建菜单项
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 会员管理一级菜单(目录)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(500, '会员管理', 0, 5, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 会员管理子菜单(页面)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(501, '会员管理', 500, 1, 'C', 'business:member:list', 'member/member/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
|
||||||
|
(5011, '会员查询', 501, 1, 'F', 'business:member:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(5012, '会员新增', 501, 2, 'F', 'business:member:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(5013, '会员修改', 501, 3, 'F', 'business:member:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(5014, '会员删除', 501, 4, 'F', 'business:member:remove', NULL, 1, NOW(), NOW()),
|
||||||
|
(5015, '会员详情', 501, 5, 'F', 'business:member:detail', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
+2
-1
@@ -218,7 +218,8 @@ VALUES
|
|||||||
('主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue', 'Y', 'system', 'system', NOW(), NOW()),
|
('主框架页-默认皮肤样式名称', 'sys.index.skinName', 'skin-blue', 'Y', 'system', 'system', NOW(), NOW()),
|
||||||
('用户自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'system', 'system', NOW(), NOW()),
|
('用户自助-验证码开关', 'sys.account.captchaEnabled', 'true', 'Y', 'system', 'system', NOW(), NOW()),
|
||||||
('用户自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'system', 'system', NOW(), NOW()),
|
('用户自助-是否开启用户注册功能', 'sys.account.registerUser', 'false', 'Y', 'system', 'system', NOW(), NOW()),
|
||||||
('账号自助-密码验证码', 'sys.account.pwdCaptchaEnabled', 'true', 'Y', 'system', 'system', NOW(), NOW())
|
('账号自助-密码验证码', 'sys.account.pwdCaptchaEnabled', 'true', 'Y', 'system', 'system', NOW(), NOW()),
|
||||||
|
('团课-取消预约免手续费次数', 'group_course.cancel_free_limit', '3', 'Y', 'system', 'system', NOW(), NOW())
|
||||||
ON CONFLICT (config_key) DO NOTHING;
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V30: 新增会员卡管理菜单
|
||||||
|
-- 描述: 为会员卡管理功能创建菜单项
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 会员卡管理一级菜单(目录)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(502, '会员卡管理', 0, 6, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 会员卡管理子菜单(页面)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(503, '会员卡管理', 502, 1, 'C', 'business:memberCard:list', 'member/card/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
|
||||||
|
(5031, '会员卡查询', 503, 1, 'F', 'business:memberCard:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(5032, '会员卡新增', 503, 2, 'F', 'business:memberCard:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(5033, '会员卡修改', 503, 3, 'F', 'business:memberCard:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(5034, '会员卡删除', 503, 4, 'F', 'business:memberCard:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V31: 新增数据统计菜单
|
||||||
|
-- 描述: 为数据统计看板功能创建菜单项
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 数据统计一级菜单(目录)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(504, '数据统计', 0, 7, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 数据统计子菜单(页面)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(505, '数据统计看板', 504, 1, 'C', 'business:statistics:view', 'statistics/dashboard/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
|
||||||
|
(5051, '数据统计导出', 505, 1, 'F', 'business:statistics:export', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V32: 新增测试数据(用于数据统计看板展示)
|
||||||
|
-- 描述: 基于当天日期,创建跨多个时间区间的会员、签到、预约数据
|
||||||
|
-- 覆盖时间区间: 今日、本周、本月、近30天、近90天、今年
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. 新增会员(100人,ID 2001-2100)
|
||||||
|
-- 按时间分布:今日10人 / 本周20人 / 本月30人 / 近30天60人 / 近90天85人 / 今年100人
|
||||||
|
-- 创建日期从 180 天前均匀分布到今天
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO member_user (id, member_no, nickname, phone, gender, subscribed, is_deleted, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
2000 + g,
|
||||||
|
'M' || TO_CHAR(CURRENT_DATE - (g % 180), 'YYYYMMDD') || LPAD(g::text, 3, '0'),
|
||||||
|
'测试会员' || g,
|
||||||
|
'138' || LPAD(g::text, 8, '0'),
|
||||||
|
(g % 3)::int,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
(CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp,
|
||||||
|
(CURRENT_DATE - (g % 180) + TIME '10:00:00')::timestamp
|
||||||
|
FROM generate_series(1, 100) AS g
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. 签到记录(80条,使用已有会员 ID 1001-1010)
|
||||||
|
-- 按时间分布:最近90天,每天约1条
|
||||||
|
-- 签到类型:QR_CODE/MANUAL/FACE 均匀分布
|
||||||
|
-- 签到状态:SUCCESS(90%) / FAILED(10%)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO sign_in_record (member_id, sign_in_time, sign_in_type, sign_in_status, source, is_delete, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
1001 + (g % 10),
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '08:00:00' + (INTERVAL '1 hour' * (g % 12)))::timestamp,
|
||||||
|
CASE (g % 3) WHEN 0 THEN 'QR_CODE' WHEN 1 THEN 'MANUAL' ELSE 'FACE' END,
|
||||||
|
CASE WHEN (g % 10) = 0 THEN 'FAILED' ELSE 'SUCCESS' END,
|
||||||
|
CASE (g % 2) WHEN 0 THEN 'MINI_PROGRAM' ELSE 'PC_BACKEND' END,
|
||||||
|
false,
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp,
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '08:00:00')::timestamp
|
||||||
|
FROM generate_series(1, 80) AS g;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. 团课预约(40条,使用已有会员 ID 1001-1010)
|
||||||
|
-- 按时间分布:最近90天
|
||||||
|
-- 状态分布:出勤(2) 60% / 取消(1) 20% / 缺席(3) 20%
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO group_course_booking (id, course_id, member_id, member_card_id, booking_time, status, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
5100 + g,
|
||||||
|
CASE (g % 3) WHEN 0 THEN 3001 WHEN 1 THEN 3002 ELSE 3003 END,
|
||||||
|
1001 + (g % 10),
|
||||||
|
(SELECT id FROM member_card_record ORDER BY id LIMIT 1),
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp,
|
||||||
|
CASE WHEN (g % 5) = 0 THEN '1' WHEN (g % 5) = 1 THEN '3' ELSE '2' END,
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp,
|
||||||
|
(CURRENT_DATE - (g % 90) + TIME '10:00:00')::timestamp
|
||||||
|
FROM generate_series(1, 40) AS g;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. 重置序列(防止后续自动插入 ID 冲突)
|
||||||
|
-- ============================================================
|
||||||
|
SELECT setval('member_user_id_seq', (SELECT COALESCE(MAX(id), 1) FROM member_user));
|
||||||
|
SELECT setval('group_course_booking_id_seq', (SELECT COALESCE(MAX(id), 1) FROM group_course_booking));
|
||||||
|
SELECT setval('sign_in_record_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sign_in_record));
|
||||||
@@ -106,6 +106,12 @@
|
|||||||
<groupId>org.apache.poi</groupId>
|
<groupId>org.apache.poi</groupId>
|
||||||
<artifactId>poi-ooxml</artifactId>
|
<artifactId>poi-ooxml</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Fix commons-compress version for POI compatibility -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-compress</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -213,6 +213,11 @@
|
|||||||
<artifactId>poi-ooxml</artifactId>
|
<artifactId>poi-ooxml</artifactId>
|
||||||
<version>${poi.version}</version>
|
<version>${poi.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-compress</artifactId>
|
||||||
|
<version>1.21</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// ==================== 团课课程 ====================
|
||||||
|
|
||||||
|
export interface GroupCourse {
|
||||||
|
id?: number
|
||||||
|
courseName: string
|
||||||
|
coachId?: number
|
||||||
|
courseType?: number
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
maxMembers?: number
|
||||||
|
currentMembers?: number
|
||||||
|
status?: string
|
||||||
|
actualStartTime?: string
|
||||||
|
actualEndTime?: string
|
||||||
|
location?: string
|
||||||
|
coverImage?: string
|
||||||
|
description?: string
|
||||||
|
storedValueAmount?: number
|
||||||
|
qrCodePath?: string
|
||||||
|
createBy?: string
|
||||||
|
updateBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupCoursePageRequest {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
status?: string
|
||||||
|
courseType?: number
|
||||||
|
coachId?: number
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
sortBy?: string
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
size: number
|
||||||
|
number: number
|
||||||
|
first: boolean
|
||||||
|
last: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupCourseApi = {
|
||||||
|
getAll: () =>
|
||||||
|
request.get<GroupCourse[]>('/groupCourse/list'),
|
||||||
|
|
||||||
|
getPage: (params: GroupCoursePageRequest) =>
|
||||||
|
request.post<PageResponse<GroupCourse>>('/groupCourse/page', params),
|
||||||
|
|
||||||
|
getById: (id: number) =>
|
||||||
|
request.get<GroupCourse>(`/groupCourse/${id}`),
|
||||||
|
|
||||||
|
getDetailById: (id: number) =>
|
||||||
|
request.get<GroupCourse>(`/groupCourse/${id}/detail`),
|
||||||
|
|
||||||
|
create: (data: GroupCourse) =>
|
||||||
|
request.post<GroupCourse>('/groupCourse', data),
|
||||||
|
|
||||||
|
update: (id: number, data: GroupCourse) =>
|
||||||
|
request.put<GroupCourse>(`/groupCourse/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/${id}`),
|
||||||
|
|
||||||
|
cancel: (id: number) =>
|
||||||
|
request.post<void>(`/groupCourse/${id}/cancel`),
|
||||||
|
|
||||||
|
search: (params: any) =>
|
||||||
|
request.post<GroupCourse[]>('/groupCourse/search', params),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 团课类型 ====================
|
||||||
|
|
||||||
|
export interface GroupCourseType {
|
||||||
|
id?: number
|
||||||
|
typeName: string
|
||||||
|
baseDifficulty?: number
|
||||||
|
description?: string
|
||||||
|
category?: string
|
||||||
|
createBy?: string
|
||||||
|
updateBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupCourseTypePageRequest {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
category?: string
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseLabelPageRequest {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupCourseTypeApi = {
|
||||||
|
getAll: () =>
|
||||||
|
request.get<GroupCourseType[]>('/groupCourse/types'),
|
||||||
|
|
||||||
|
search: (params: any) =>
|
||||||
|
request.get<GroupCourseType[]>('/groupCourse/types/search', { params }),
|
||||||
|
|
||||||
|
getCategories: () =>
|
||||||
|
request.get<string[]>('/groupCourse/types/categories'),
|
||||||
|
|
||||||
|
getByCategory: (category: string) =>
|
||||||
|
request.get<GroupCourseType[]>(`/groupCourse/types/category/${encodeURIComponent(category)}`),
|
||||||
|
|
||||||
|
getById: (id: number) =>
|
||||||
|
request.get<GroupCourseType>(`/groupCourse/types/${id}`),
|
||||||
|
|
||||||
|
getPage: (params: GroupCourseTypePageRequest) =>
|
||||||
|
request.post<PageResponse<GroupCourseType>>('/groupCourse/types/page', params),
|
||||||
|
|
||||||
|
create: (data: GroupCourseType) =>
|
||||||
|
request.post<GroupCourseType>('/groupCourse/types', data),
|
||||||
|
|
||||||
|
update: (id: number, data: GroupCourseType) =>
|
||||||
|
request.put<GroupCourseType>(`/groupCourse/types/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/types/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 团课标签 ====================
|
||||||
|
|
||||||
|
export interface CourseLabel {
|
||||||
|
id?: number
|
||||||
|
labelName: string
|
||||||
|
color?: string
|
||||||
|
description?: string
|
||||||
|
createBy?: string
|
||||||
|
updateBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const courseLabelApi = {
|
||||||
|
getAll: () =>
|
||||||
|
request.get<CourseLabel[]>('/groupCourse/labels'),
|
||||||
|
|
||||||
|
search: (params: any) =>
|
||||||
|
request.get<CourseLabel[]>('/groupCourse/labels/search', { params }),
|
||||||
|
|
||||||
|
getById: (id: number) =>
|
||||||
|
request.get<CourseLabel>(`/groupCourse/labels/${id}`),
|
||||||
|
|
||||||
|
getByTypeId: (typeId: number) =>
|
||||||
|
request.get<CourseLabel[]>(`/groupCourse/types/${typeId}/labels`),
|
||||||
|
|
||||||
|
getPage: (params: CourseLabelPageRequest) =>
|
||||||
|
request.post<PageResponse<CourseLabel>>('/groupCourse/labels/page', params),
|
||||||
|
|
||||||
|
create: (data: CourseLabel) =>
|
||||||
|
request.post<CourseLabel>('/groupCourse/labels', data),
|
||||||
|
|
||||||
|
update: (id: number, data: CourseLabel) =>
|
||||||
|
request.put<CourseLabel>(`/groupCourse/labels/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/labels/${id}`),
|
||||||
|
|
||||||
|
addLabelsToType: (typeId: number, labelIds: number[]) =>
|
||||||
|
request.post<void>(`/groupCourse/types/${typeId}/labels`, { labelIds }),
|
||||||
|
|
||||||
|
removeLabelFromType: (typeId: number, labelId: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/types/${typeId}/labels/${labelId}`),
|
||||||
|
|
||||||
|
clearLabelsFromType: (typeId: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/types/${typeId}/labels`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 团课推荐 ====================
|
||||||
|
|
||||||
|
export interface GroupCourseRecommend {
|
||||||
|
id?: number
|
||||||
|
courseId: number
|
||||||
|
recommendTitle?: string
|
||||||
|
recommendContent?: string
|
||||||
|
recommendReason?: string
|
||||||
|
priority?: number
|
||||||
|
isActive?: boolean
|
||||||
|
createBy?: string
|
||||||
|
updateBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupCourseRecommendApi = {
|
||||||
|
getAll: () =>
|
||||||
|
request.get<GroupCourseRecommend[]>('/groupCourse/recommend/list'),
|
||||||
|
|
||||||
|
getActive: () =>
|
||||||
|
request.get<GroupCourseRecommend[]>('/groupCourse/recommend/active'),
|
||||||
|
|
||||||
|
getById: (id: number) =>
|
||||||
|
request.get<GroupCourseRecommend>(`/groupCourse/recommend/${id}`),
|
||||||
|
|
||||||
|
getByCourseId: (courseId: number) =>
|
||||||
|
request.get<GroupCourseRecommend[]>(`/groupCourse/recommend/course/${courseId}`),
|
||||||
|
|
||||||
|
create: (data: GroupCourseRecommend) =>
|
||||||
|
request.post<GroupCourseRecommend>('/groupCourse/recommend', data),
|
||||||
|
|
||||||
|
update: (id: number, data: GroupCourseRecommend) =>
|
||||||
|
request.put<GroupCourseRecommend>(`/groupCourse/recommend/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete<void>(`/groupCourse/recommend/${id}`),
|
||||||
|
|
||||||
|
enable: (id: number) =>
|
||||||
|
request.post<void>(`/groupCourse/recommend/${id}/enable`),
|
||||||
|
|
||||||
|
disable: (id: number) =>
|
||||||
|
request.post<void>(`/groupCourse/recommend/${id}/disable`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 团课预约 ====================
|
||||||
|
|
||||||
|
export interface GroupCourseBooking {
|
||||||
|
id?: number
|
||||||
|
courseId: number
|
||||||
|
memberId: number
|
||||||
|
memberCardId: number
|
||||||
|
bookingTime?: string
|
||||||
|
status?: string
|
||||||
|
cancelTime?: string
|
||||||
|
courseName?: string
|
||||||
|
courseStartTime?: string
|
||||||
|
courseEndTime?: string
|
||||||
|
location?: string
|
||||||
|
createBy?: string
|
||||||
|
updateBy?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupCourseBookingApi = {
|
||||||
|
book: (data: GroupCourseBooking) =>
|
||||||
|
request.post<GroupCourseBooking>('/groupCourse/book', data),
|
||||||
|
|
||||||
|
cancel: (bookingId: number) =>
|
||||||
|
request.post<void>(`/groupCourse/booking/${bookingId}/cancel`),
|
||||||
|
|
||||||
|
getByMemberId: (memberId: number) =>
|
||||||
|
request.get<GroupCourseBooking[]>(`/groupCourse/bookings/member/${memberId}`),
|
||||||
|
|
||||||
|
getByCourseId: (courseId: number) =>
|
||||||
|
request.get<GroupCourseBooking[]>(`/groupCourse/bookings/course/${courseId}`),
|
||||||
|
|
||||||
|
getById: (bookingId: number) =>
|
||||||
|
request.get<GroupCourseBooking>(`/groupCourse/bookings/${bookingId}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// ==================== 会员基础信息 ====================
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
id?: number | string
|
||||||
|
memberNo?: string
|
||||||
|
nickname?: string
|
||||||
|
phone?: string
|
||||||
|
gender?: number
|
||||||
|
genderDesc?: string
|
||||||
|
avatar?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberDetail {
|
||||||
|
id?: number
|
||||||
|
memberNo?: string
|
||||||
|
nickname?: string
|
||||||
|
phone?: string
|
||||||
|
gender?: number
|
||||||
|
genderDesc?: string
|
||||||
|
birthday?: string
|
||||||
|
address?: string
|
||||||
|
avatar?: string
|
||||||
|
subscribed?: boolean
|
||||||
|
lastLoginAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
memberCards?: MemberCardInfo[]
|
||||||
|
activeCardCount?: number
|
||||||
|
inactiveCardCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCardInfo {
|
||||||
|
id?: number
|
||||||
|
memberCardId?: number
|
||||||
|
memberCardName?: string
|
||||||
|
memberCardType?: string
|
||||||
|
memberCardTypeDesc?: string
|
||||||
|
memberCardPrice?: number
|
||||||
|
memberCardValidityDays?: number
|
||||||
|
memberCardTotalTimes?: number
|
||||||
|
memberCardAmount?: number
|
||||||
|
memberCardStatus?: string
|
||||||
|
memberCardStatusDesc?: string
|
||||||
|
extraConfig?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCardRecord {
|
||||||
|
id?: number
|
||||||
|
memberCardRecordId?: number
|
||||||
|
memberId?: number
|
||||||
|
memberCardId?: number
|
||||||
|
memberCardName?: string
|
||||||
|
memberCardType?: string
|
||||||
|
memberCardPrice?: number
|
||||||
|
memberCardValidityDays?: number
|
||||||
|
memberCardTotalTimes?: number
|
||||||
|
status?: string
|
||||||
|
remainingTimes?: number
|
||||||
|
remainingAmount?: number
|
||||||
|
expireTime?: string
|
||||||
|
purchaseTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberDto {
|
||||||
|
nickname?: string
|
||||||
|
gender?: string
|
||||||
|
birthday?: string
|
||||||
|
avatar?: string
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePhoneDto {
|
||||||
|
phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memberApi = {
|
||||||
|
/** 搜索会员 */
|
||||||
|
search: (params: { searchValue?: string; pageNum?: number; pageSize?: number }) =>
|
||||||
|
request.get<Member[]>('/admin/members', { params }),
|
||||||
|
|
||||||
|
/** 获取所有会员 */
|
||||||
|
getAll: (params: { pageNum?: number; pageSize?: number; sortField?: string; sortOrder?: string }) =>
|
||||||
|
request.get<Member[]>('/admin/members/all', { params }),
|
||||||
|
|
||||||
|
/** 获取会员详情 */
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<MemberDetail>(`/admin/member/${id}`),
|
||||||
|
|
||||||
|
/** 更新会员信息 */
|
||||||
|
update: (id: number, data: UpdateMemberDto) =>
|
||||||
|
request.put<void>(`/admin/member/${id}`, data),
|
||||||
|
|
||||||
|
/** 管理员更新会员手机号 */
|
||||||
|
updatePhone: (id: number, data: UpdatePhoneDto) =>
|
||||||
|
request.post<void>(`/admin/member/${id}/phone`, data),
|
||||||
|
|
||||||
|
/** 获取会员绑定的会员卡记录 */
|
||||||
|
getMemberCardRecords: (memberId: number) =>
|
||||||
|
request.get<MemberCardRecord[]>(`/member-card-records/my-cards-with-status/${memberId}`, {
|
||||||
|
params: { status: 'all' },
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// ==================== 会员卡类型 ====================
|
||||||
|
|
||||||
|
export interface MemberCardType {
|
||||||
|
id?: number
|
||||||
|
memberCardId?: number
|
||||||
|
memberCardName?: string
|
||||||
|
memberCardType?: string
|
||||||
|
memberCardTypeDesc?: string
|
||||||
|
memberCardPrice?: number
|
||||||
|
memberCardValidityDays?: number
|
||||||
|
memberCardTotalTimes?: number
|
||||||
|
memberCardAmount?: number
|
||||||
|
memberCardStatus?: number
|
||||||
|
memberCardStatusDesc?: string
|
||||||
|
extraConfig?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCardPageParams {
|
||||||
|
status?: number
|
||||||
|
name?: string
|
||||||
|
type?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memberCardApi = {
|
||||||
|
/** 查询会员卡类型列表(分页+条件) */
|
||||||
|
list: (params: MemberCardPageParams) =>
|
||||||
|
request.get<MemberCardType[]>('/member-cards', { params }),
|
||||||
|
|
||||||
|
/** 查询上架中的会员卡类型 */
|
||||||
|
getActiveCards: (status?: number) =>
|
||||||
|
request.get<MemberCardType[]>('/member-cards/active', { params: { status } }),
|
||||||
|
|
||||||
|
/** 根据ID查询会员卡类型 */
|
||||||
|
getById: (memberCardId: number) =>
|
||||||
|
request.get<MemberCardType>(`/member-cards/${memberCardId}`),
|
||||||
|
|
||||||
|
/** 创建会员卡类型 */
|
||||||
|
create: (data: MemberCardType) =>
|
||||||
|
request.post<MemberCardType>('/member-cards', data),
|
||||||
|
|
||||||
|
/** 更新会员卡类型 */
|
||||||
|
update: (id: number, data: MemberCardType) =>
|
||||||
|
request.put<void>(`/member-cards/${id}`, data),
|
||||||
|
|
||||||
|
/** 删除会员卡类型 */
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete<void>(`/member-cards/${id}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// ========== 接口类型定义 ==========
|
||||||
|
|
||||||
|
export interface MemberStatistics {
|
||||||
|
statDate: string
|
||||||
|
newMembers: number
|
||||||
|
activeMembers: number
|
||||||
|
totalMembers: number
|
||||||
|
signInMembers: number
|
||||||
|
bookingMembers: number
|
||||||
|
cancelBookingMembers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingStatistics {
|
||||||
|
statDate: string
|
||||||
|
newBookings: number
|
||||||
|
cancelBookings: number
|
||||||
|
attendBookings: number
|
||||||
|
absentBookings: number
|
||||||
|
attendanceRate: number
|
||||||
|
cancelRate: number
|
||||||
|
bookingMembers: number
|
||||||
|
cancelMembers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignInStatistics {
|
||||||
|
statDate: string
|
||||||
|
totalSignIns: number
|
||||||
|
successSignIns: number
|
||||||
|
failedSignIns: number
|
||||||
|
successRate: number
|
||||||
|
signInMembers: number
|
||||||
|
qrCodeSignIns: number
|
||||||
|
manualSignIns: number
|
||||||
|
faceSignIns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatisticsSummary {
|
||||||
|
statDate: string
|
||||||
|
memberStatistics: MemberStatistics
|
||||||
|
bookingStatistics: BookingStatistics
|
||||||
|
signInStatistics: SignInStatistics
|
||||||
|
generatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryRecord {
|
||||||
|
statType: string
|
||||||
|
periodType: string
|
||||||
|
statDate: string
|
||||||
|
count: number
|
||||||
|
relatedId: number
|
||||||
|
extraData: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatisticsQuery {
|
||||||
|
statType?: string
|
||||||
|
periodType?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== API ==========
|
||||||
|
|
||||||
|
export const statisticsApi = {
|
||||||
|
/** 获取综合统计摘要 */
|
||||||
|
getSummary(params?: StatisticsQuery) {
|
||||||
|
return request.get<any, StatisticsSummary>('/datacount/summary', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取会员统计 */
|
||||||
|
getMember(params?: StatisticsQuery) {
|
||||||
|
return request.get<any, MemberStatistics>('/datacount/member', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取预约统计 */
|
||||||
|
getBooking(params?: StatisticsQuery) {
|
||||||
|
return request.get<any, BookingStatistics>('/datacount/booking', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取签到统计 */
|
||||||
|
getSignIn(params?: StatisticsQuery) {
|
||||||
|
return request.get<any, SignInStatistics>('/datacount/signin', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 查询历史统计数据 */
|
||||||
|
getHistory(params?: StatisticsQuery) {
|
||||||
|
return request.get<any, HistoryRecord[]>('/datacount/history', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 导出统计报表 (Excel) */
|
||||||
|
exportStatistics(params?: StatisticsQuery) {
|
||||||
|
return request.get('/datacount/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -97,15 +97,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
} else if (!permissionStore.loaded) {
|
} else {
|
||||||
permissionStore.initFromStorage()
|
// 先尝试从 localStorage 快速恢复,再异步获取最新菜单
|
||||||
|
if (!permissionStore.loaded) {
|
||||||
if (!permissionStore.loaded || permissionStore.menus.length === 0) {
|
permissionStore.initFromStorage()
|
||||||
try {
|
}
|
||||||
await permissionStore.fetchUserMenus()
|
try {
|
||||||
} catch (error) {
|
await permissionStore.fetchUserMenus()
|
||||||
console.error('获取用户菜单失败:', error)
|
} catch (error) {
|
||||||
}
|
console.error('获取用户菜单失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,6 +93,48 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'ExceptionLog',
|
name: 'ExceptionLog',
|
||||||
component: () => import('@/views/audit/ExceptionLog.vue'),
|
component: () => import('@/views/audit/ExceptionLog.vue'),
|
||||||
meta: { title: '异常日志' }
|
meta: { title: '异常日志' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'courses',
|
||||||
|
name: 'GroupCourseManagement',
|
||||||
|
component: () => import('@/views/groupcourse/GroupCourseManagement.vue'),
|
||||||
|
meta: { title: '团课管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'courses/types',
|
||||||
|
name: 'CourseTypeManagement',
|
||||||
|
component: () => import('@/views/groupcourse/CourseTypeManagement.vue'),
|
||||||
|
meta: { title: '团课类型' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'courses/labels',
|
||||||
|
name: 'CourseLabelManagement',
|
||||||
|
component: () => import('@/views/groupcourse/CourseLabelManagement.vue'),
|
||||||
|
meta: { title: '团课标签' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'courses/recommend',
|
||||||
|
name: 'CourseRecommendManagement',
|
||||||
|
component: () => import('@/views/groupcourse/CourseRecommendManagement.vue'),
|
||||||
|
meta: { title: '团课推荐' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'members',
|
||||||
|
name: 'MemberManagement',
|
||||||
|
component: () => import('@/views/member/MemberManagement.vue'),
|
||||||
|
meta: { title: '会员管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'member-cards',
|
||||||
|
name: 'MemberCardManagement',
|
||||||
|
component: () => import('@/views/member/MemberCardManagement.vue'),
|
||||||
|
meta: { title: '会员卡管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'statistics',
|
||||||
|
name: 'StatisticsDashboard',
|
||||||
|
component: () => import('@/views/statistics/StatisticsDashboard.vue'),
|
||||||
|
meta: { title: '数据统计看板' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
|
|||||||
'audit/operation/index': '/oplog',
|
'audit/operation/index': '/oplog',
|
||||||
'audit/login/index': '/loginlog',
|
'audit/login/index': '/loginlog',
|
||||||
'audit/exception/index': '/exceptionlog',
|
'audit/exception/index': '/exceptionlog',
|
||||||
|
'gymCourse/course/index': '/courses',
|
||||||
|
'gymCourse/type/index': '/courses/types',
|
||||||
|
'gymCourse/label/index': '/courses/labels',
|
||||||
|
'gymCourse/recommend/index': '/courses/recommend',
|
||||||
|
'member/member/index': '/members',
|
||||||
|
'member/card/index': '/member-cards',
|
||||||
|
'statistics/dashboard/index': '/statistics',
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
|
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
|
||||||
@@ -92,7 +99,14 @@ function getMenuIcon(menuName: string): string {
|
|||||||
'文件管理': 'Folder',
|
'文件管理': 'Folder',
|
||||||
'操作日志': 'Document',
|
'操作日志': 'Document',
|
||||||
'登录日志': 'Document',
|
'登录日志': 'Document',
|
||||||
'异常日志': 'Warning'
|
'异常日志': 'Warning',
|
||||||
|
'团课管理': 'TrophyBase',
|
||||||
|
'团课类型': 'Grid',
|
||||||
|
'团课标签': 'Discount',
|
||||||
|
'团课推荐': 'Star',
|
||||||
|
'会员管理': 'Avatar',
|
||||||
|
'会员卡管理': 'CreditCard',
|
||||||
|
'数据统计': 'DataAnalysis',
|
||||||
}
|
}
|
||||||
return iconMap[menuName] || 'Document'
|
return iconMap[menuName] || 'Document'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-label-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="search-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索标签名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@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="dataSource"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="labelName" label="标签名称" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:color="row.color"
|
||||||
|
effect="dark"
|
||||||
|
style="font-weight: 500; font-size: 14px; border-color: transparent;"
|
||||||
|
>
|
||||||
|
{{ row.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="color" label="颜色" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: row.color,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span>{{ row.color }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="标签名称" prop="labelName">
|
||||||
|
<el-input v-model="formState.labelName" placeholder="请输入标签名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签颜色" prop="color">
|
||||||
|
<el-color-picker v-model="formState.color" show-alpha />
|
||||||
|
<span style="margin-left: 8px; color: #909399;">{{ formState.color }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入标签描述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modalVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleModalOk">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { courseLabelApi, type CourseLabel, type CourseLabelPageRequest } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<CourseLabel[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const formState = reactive<CourseLabel>({
|
||||||
|
labelName: '',
|
||||||
|
color: '#1890ff',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
labelName: [
|
||||||
|
{ required: true, message: '请输入标签名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '标签名称长度在 2 到 50 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
color: [{ required: true, message: '请选择标签颜色', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: CourseLabelPageRequest = {
|
||||||
|
page: currentPage.value - 1,
|
||||||
|
size: pageSize.value,
|
||||||
|
sort: 'id',
|
||||||
|
order: 'asc',
|
||||||
|
}
|
||||||
|
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||||
|
|
||||||
|
const res = await courseLabelApi.getPage(params)
|
||||||
|
dataSource.value = res.content
|
||||||
|
total.value = Number(res.totalElements)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = () => fetchData()
|
||||||
|
const onSizeChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
modalTitle.value = '新增标签'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
labelName: '',
|
||||||
|
color: '#1890ff',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: CourseLabel) => {
|
||||||
|
modalTitle.value = '编辑标签'
|
||||||
|
Object.assign(formState, { ...row })
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: CourseLabel) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该标签吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await courseLabelApi.delete(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
// Convert RGBA to hex before submitting
|
||||||
|
const data = { ...formState }
|
||||||
|
if (data.color?.startsWith('rgba')) {
|
||||||
|
data.color = rgbaToHex(data.color)
|
||||||
|
}
|
||||||
|
if (formState.id) {
|
||||||
|
await courseLabelApi.update(formState.id, data)
|
||||||
|
} else {
|
||||||
|
await courseLabelApi.create(data)
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
modalVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert rgba(r, g, b, a) to #rrggbb or #rrggbbaa hex */
|
||||||
|
const rgbaToHex = (rgba: string): string => {
|
||||||
|
const match = rgba.match(/[\d.]+/g)
|
||||||
|
if (!match || match.length < 3) return rgba
|
||||||
|
const r = parseInt(match[0])
|
||||||
|
const g = parseInt(match[1])
|
||||||
|
const b = parseInt(match[2])
|
||||||
|
const a = match.length >= 4 ? Math.round(parseFloat(match[3]) * 255) : 255
|
||||||
|
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
||||||
|
const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
|
return a < 255 ? `${hex}${toHex(a)}` : hex
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchData())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.course-label-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-recommend-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-title">
|
||||||
|
<span>团课推荐管理</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button size="small" @click="resetSort">恢复默认排序</el-button>
|
||||||
|
<el-button type="primary" @click="handleAdd">新增推荐</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="sortedData"
|
||||||
|
style="width: 100%"
|
||||||
|
@sort-change="onSortChange"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="100" sortable="custom" />
|
||||||
|
<el-table-column prop="courseName" label="团课名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ courseMap[row.courseId] || `课程 #${row.courseId}` }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="recommendTitle" label="推荐标题" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="priority" label="优先级" width="100" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.priority >= 5 ? 'danger' : row.priority >= 3 ? 'warning' : 'info'"
|
||||||
|
effect="dark"
|
||||||
|
style="font-weight: 500; font-size: 14px;"
|
||||||
|
>
|
||||||
|
{{ row.priority }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.isActive ? 'success' : 'danger'"
|
||||||
|
effect="dark"
|
||||||
|
style="font-weight: 500; font-size: 14px;"
|
||||||
|
>
|
||||||
|
{{ row.isActive ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="recommendReason" label="推荐理由" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="170" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.isActive"
|
||||||
|
type="warning"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleToggleActive(row)"
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
type="success"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleToggleActive(row)"
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="550px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="关联团课" prop="courseId">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.courseId"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择团课"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="course in courseList"
|
||||||
|
:key="course.id"
|
||||||
|
:value="course.id!"
|
||||||
|
:label="course.courseName"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="推荐标题" prop="recommendTitle">
|
||||||
|
<el-input v-model="formState.recommendTitle" placeholder="请输入推荐标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="推荐内容">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.recommendContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入推荐内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="推荐理由">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.recommendReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="请输入推荐理由"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级" prop="priority">
|
||||||
|
<el-input-number v-model="formState.priority" :min="0" :max="20" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modalVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleModalOk">确定</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 { groupCourseRecommendApi, groupCourseApi, type GroupCourseRecommend, type GroupCourse } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<GroupCourseRecommend[]>([])
|
||||||
|
const courseMap = ref<Record<number, string>>({})
|
||||||
|
const courseList = ref<GroupCourse[]>([])
|
||||||
|
const sortState = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'priority', order: 'descending' })
|
||||||
|
|
||||||
|
const sortedData = computed(() => {
|
||||||
|
const list = [...dataSource.value]
|
||||||
|
const { prop, order } = sortState.value
|
||||||
|
if (!order) return list
|
||||||
|
const dir = order === 'ascending' ? 1 : -1
|
||||||
|
const getName = (courseId: number) => courseMap.value[courseId] || ''
|
||||||
|
switch (prop) {
|
||||||
|
case 'id':
|
||||||
|
return list.sort((a, b) => ((a.id || 0) - (b.id || 0)) * dir)
|
||||||
|
case 'priority':
|
||||||
|
return list.sort((a, b) => ((a.priority || 0) - (b.priority || 0)) * dir)
|
||||||
|
case 'courseName':
|
||||||
|
return list.sort((a, b) => getName(a.courseId).localeCompare(getName(b.courseId), 'zh') * dir)
|
||||||
|
case 'createdAt':
|
||||||
|
return list.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || '') * dir)
|
||||||
|
default:
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSortChange = ({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) => {
|
||||||
|
sortState.value = { prop, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableRef = ref()
|
||||||
|
const resetSort = () => {
|
||||||
|
tableRef.value?.clearSort()
|
||||||
|
sortState.value = { prop: 'priority', order: 'descending' }
|
||||||
|
// Re-apply default sort arrows on the table
|
||||||
|
tableRef.value?.sort('priority', 'descending')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const formState = reactive<GroupCourseRecommend>({
|
||||||
|
courseId: 0,
|
||||||
|
recommendTitle: '',
|
||||||
|
recommendContent: '',
|
||||||
|
recommendReason: '',
|
||||||
|
priority: 0,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
courseId: [{ required: true, message: '请选择团课', trigger: 'change' }],
|
||||||
|
recommendTitle: [
|
||||||
|
{ required: true, message: '请输入推荐标题', trigger: 'blur' },
|
||||||
|
{ max: 200, message: '标题长度不能超过200个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
priority: [{ required: true, message: '请设置优先级', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [recommends, courses] = await Promise.all([
|
||||||
|
groupCourseRecommendApi.getAll(),
|
||||||
|
groupCourseApi.getAll(),
|
||||||
|
])
|
||||||
|
dataSource.value = recommends
|
||||||
|
courseList.value = courses
|
||||||
|
// Build courseId -> courseName map
|
||||||
|
const map: Record<number, string> = {}
|
||||||
|
courses.forEach((c) => {
|
||||||
|
if (c.id != null) map[c.id] = c.courseName
|
||||||
|
})
|
||||||
|
courseMap.value = map
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
modalTitle.value = '新增推荐'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
courseId: 0,
|
||||||
|
recommendTitle: '',
|
||||||
|
recommendContent: '',
|
||||||
|
recommendReason: '',
|
||||||
|
priority: 0,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: GroupCourseRecommend) => {
|
||||||
|
modalTitle.value = '编辑推荐'
|
||||||
|
Object.assign(formState, { ...row })
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActive = async (row: GroupCourseRecommend) => {
|
||||||
|
try {
|
||||||
|
const actionText = row.isActive ? '停用' : '启用'
|
||||||
|
await ElMessageBox.confirm(`确定要${actionText}该推荐吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
if (row.isActive) {
|
||||||
|
await groupCourseRecommendApi.disable(row.id!)
|
||||||
|
} else {
|
||||||
|
await groupCourseRecommendApi.enable(row.id!)
|
||||||
|
}
|
||||||
|
ElMessage.success(`${actionText}成功`)
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: GroupCourseRecommend) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该推荐吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await groupCourseRecommendApi.delete(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
if (formState.id) {
|
||||||
|
await groupCourseRecommendApi.update(formState.id, formState)
|
||||||
|
} else {
|
||||||
|
await groupCourseRecommendApi.create(formState)
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
modalVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchData())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.course-recommend-management .card-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-recommend-management .header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-type-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="search-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索类型名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="searchCategory"
|
||||||
|
placeholder="筛选分类"
|
||||||
|
clearable
|
||||||
|
style="width: 160px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat"
|
||||||
|
:value="cat"
|
||||||
|
:label="cat"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<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="dataSource"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||||
|
<el-table-column prop="typeName" label="类型名称" min-width="180" />
|
||||||
|
<el-table-column label="基础难度" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ difficultyLabel(row.baseDifficulty) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="category" label="分类" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="dark" style="font-weight: 500; font-size: 14px;">
|
||||||
|
{{ row.category || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="标签" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="typeLabelsMap[row.id!]?.length">
|
||||||
|
<el-tag
|
||||||
|
v-for="label in typeLabelsMap[row.id!]"
|
||||||
|
:key="label.id"
|
||||||
|
:color="label.color"
|
||||||
|
effect="dark"
|
||||||
|
size="small"
|
||||||
|
style="margin-right: 4px; border-color: transparent;"
|
||||||
|
>
|
||||||
|
{{ label.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: #999;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="650px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="类型名称" prop="typeName">
|
||||||
|
<el-input v-model="formState.typeName" placeholder="请输入类型名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="基础难度" prop="baseDifficulty">
|
||||||
|
<el-slider v-model="formState.baseDifficulty" :min="1" :max="10" show-stops style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类" prop="category">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.category"
|
||||||
|
placeholder="请选择分类"
|
||||||
|
allow-create
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat"
|
||||||
|
:value="cat"
|
||||||
|
:label="cat"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入类型描述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="formState.id" label="类型标签">
|
||||||
|
<div class="label-select-area">
|
||||||
|
<div class="label-section-title">
|
||||||
|
已选标签({{ selectedLabelIds.length }}/5)
|
||||||
|
</div>
|
||||||
|
<div class="label-tags-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="id in selectedLabelIds"
|
||||||
|
:key="id"
|
||||||
|
:color="getLabelById(id)?.color"
|
||||||
|
effect="dark"
|
||||||
|
size="default"
|
||||||
|
closable
|
||||||
|
style="border-color: transparent; margin: 0 6px 6px 0;"
|
||||||
|
@close="toggleLabel(id)"
|
||||||
|
>
|
||||||
|
{{ getLabelById(id)?.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="selectedLabelIds.length === 0" style="color: #999;">点击下方标签选择</span>
|
||||||
|
</div>
|
||||||
|
<div class="label-section-title">
|
||||||
|
可选标签
|
||||||
|
</div>
|
||||||
|
<div class="label-tags-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="label in allLabels"
|
||||||
|
:key="label.id"
|
||||||
|
:color="label.color"
|
||||||
|
:effect="selectedLabelIds.includes(label.id!) ? 'dark' : 'plain'"
|
||||||
|
:class="{
|
||||||
|
'label-tag': true,
|
||||||
|
'label-tag--selected': selectedLabelIds.includes(label.id!),
|
||||||
|
'label-tag--disabled': !selectedLabelIds.includes(label.id!) && selectedLabelIds.length >= 5,
|
||||||
|
}"
|
||||||
|
size="default"
|
||||||
|
style="cursor: pointer; margin: 0 6px 6px 0; user-select: none;"
|
||||||
|
@click="toggleLabel(label.id!)"
|
||||||
|
>
|
||||||
|
{{ label.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="allLabels.length === 0" style="color: #999;">暂无可用标签</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modalVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleModalOk">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { groupCourseTypeApi, courseLabelApi, type GroupCourseType, type CourseLabel, type GroupCourseTypePageRequest } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<GroupCourseType[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchCategory = ref('')
|
||||||
|
const categories = ref<string[]>([])
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const allLabels = ref<CourseLabel[]>([])
|
||||||
|
const selectedLabelIds = ref<number[]>([])
|
||||||
|
const typeLabelsMap = ref<Record<number, CourseLabel[]>>({})
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const formState = reactive<GroupCourseType>({
|
||||||
|
typeName: '',
|
||||||
|
baseDifficulty: 3,
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
typeName: [
|
||||||
|
{ required: true, message: '请输入类型名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 100, message: '类型名称长度在 2 到 100 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
baseDifficulty: [
|
||||||
|
{ required: true, message: '请设置基础难度', trigger: 'change' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficultyLabel = (val?: number) => {
|
||||||
|
if (!val) return '-'
|
||||||
|
if (val <= 2) return '入门'
|
||||||
|
if (val <= 4) return '初级'
|
||||||
|
if (val <= 6) return '中级'
|
||||||
|
if (val <= 8) return '进阶'
|
||||||
|
return '高级'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
categories.value = await groupCourseTypeApi.getCategories()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllLabels = async () => {
|
||||||
|
try {
|
||||||
|
allLabels.value = await courseLabelApi.getAll()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLabel = (id: number) => {
|
||||||
|
const idx = selectedLabelIds.value.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedLabelIds.value.splice(idx, 1)
|
||||||
|
} else if (selectedLabelIds.value.length < 5) {
|
||||||
|
selectedLabelIds.value.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLabelById = (id: number): CourseLabel | undefined =>
|
||||||
|
allLabels.value.find(l => l.id === id)
|
||||||
|
|
||||||
|
const fetchTypeLabels = async (typeId: number) => {
|
||||||
|
try {
|
||||||
|
const labels = await courseLabelApi.getByTypeId(typeId)
|
||||||
|
selectedLabelIds.value = (labels || []).map(l => l.id!).filter(Boolean) as number[]
|
||||||
|
} catch {
|
||||||
|
selectedLabelIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: GroupCourseTypePageRequest = {
|
||||||
|
page: currentPage.value - 1,
|
||||||
|
size: pageSize.value,
|
||||||
|
sort: 'id',
|
||||||
|
order: 'asc',
|
||||||
|
}
|
||||||
|
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||||
|
if (searchCategory.value) params.category = searchCategory.value
|
||||||
|
|
||||||
|
const res = await groupCourseTypeApi.getPage(params)
|
||||||
|
dataSource.value = res.content
|
||||||
|
total.value = Number(res.totalElements)
|
||||||
|
loadTypeLabels()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = () => fetchData()
|
||||||
|
const onSizeChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTypeLabels = async () => {
|
||||||
|
const map: Record<number, CourseLabel[]> = {}
|
||||||
|
const promises = dataSource.value.map(async (type) => {
|
||||||
|
if (!type.id) return
|
||||||
|
try {
|
||||||
|
const labels = await courseLabelApi.getByTypeId(type.id)
|
||||||
|
map[type.id] = labels || []
|
||||||
|
} catch {
|
||||||
|
map[type.id] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(promises)
|
||||||
|
typeLabelsMap.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
modalTitle.value = '新增类型'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
typeName: '',
|
||||||
|
baseDifficulty: 3,
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
})
|
||||||
|
selectedLabelIds.value = []
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: GroupCourseType) => {
|
||||||
|
modalTitle.value = '编辑类型'
|
||||||
|
Object.assign(formState, { ...row })
|
||||||
|
modalVisible.value = true
|
||||||
|
// Load labels after dialog opens
|
||||||
|
fetchAllLabels()
|
||||||
|
if (row.id) fetchTypeLabels(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: GroupCourseType) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该团课类型吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await groupCourseTypeApi.delete(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
const msg = error?.response?.data?.message || error?.message || '删除失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
if (formState.id) {
|
||||||
|
await groupCourseTypeApi.update(formState.id, formState)
|
||||||
|
// Sync labels: clear all then add selected ones
|
||||||
|
await courseLabelApi.clearLabelsFromType(formState.id)
|
||||||
|
if (selectedLabelIds.value.length > 0) {
|
||||||
|
await courseLabelApi.addLabelsToType(formState.id, selectedLabelIds.value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await groupCourseTypeApi.create(formState)
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
modalVisible.value = false
|
||||||
|
fetchCategories()
|
||||||
|
fetchData()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
const msg = error?.response?.data?.message || error?.message || '操作失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategories()
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.course-type-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-select-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tags-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag {
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag--selected {
|
||||||
|
border-color: #fff !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
<template>
|
||||||
|
<div class="group-course-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="search-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索课程名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="searchStatus"
|
||||||
|
placeholder="课程状态"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option value="0" label="正常" />
|
||||||
|
<el-option value="1" label="已取消" />
|
||||||
|
<el-option value="2" label="已结束" />
|
||||||
|
<el-option value="3" label="进行中" />
|
||||||
|
<el-option value="4" label="超时" />
|
||||||
|
</el-select>
|
||||||
|
<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="dataSource"
|
||||||
|
style="width: 100%"
|
||||||
|
@sort-change="handleSortChange"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||||
|
<el-table-column label="封面" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cover-cell">
|
||||||
|
<img
|
||||||
|
v-if="coverCache[row.coverImage]"
|
||||||
|
:src="coverCache[row.coverImage]"
|
||||||
|
class="cover-thumb"
|
||||||
|
@error="onCoverError(row)"
|
||||||
|
/>
|
||||||
|
<span v-else class="cover-no-data">No data</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="courseName" label="课程名称" min-width="140" />
|
||||||
|
<el-table-column label="课程类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getCourseTypeName(row.courseType) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="statusMap[row.status]?.type || 'info'"
|
||||||
|
effect="dark"
|
||||||
|
style="font-weight: 500; font-size: 14px;"
|
||||||
|
>
|
||||||
|
{{ statusMap[row.status]?.label || row.status }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="startTime" label="开始时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.startTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="endTime" label="结束时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.endTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="location" label="上课地点" width="120" />
|
||||||
|
<el-table-column label="人数" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.currentMembers || 0 }}/{{ row.maxMembers || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === '0'"
|
||||||
|
type="warning"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="handleCancel(row)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.current"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@current-change="handleTableChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="modalVisible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="120px"
|
||||||
|
>
|
||||||
|
<el-form-item label="课程名称" prop="courseName">
|
||||||
|
<el-input v-model="formState.courseName" placeholder="请输入课程名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="课程类型" prop="courseType">
|
||||||
|
<el-select v-model="formState.courseType" placeholder="请选择课程类型" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="type in courseTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
:label="type.typeName"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始时间" prop="startTime">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formState.startTime"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择开始时间"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
:disabled-date="notBeforeToday"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束时间" prop="endTime" v-if="false">
|
||||||
|
<span />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="持续时间(分钟)" prop="duration">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.duration"
|
||||||
|
:min="1"
|
||||||
|
:max="1440"
|
||||||
|
placeholder="请输入课程持续时间"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大人数" prop="maxMembers">
|
||||||
|
<el-input-number v-model="formState.maxMembers" :min="1" :max="999" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上课地点" prop="location">
|
||||||
|
<el-input v-model="formState.location" placeholder="请输入上课地点" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="封面图片">
|
||||||
|
<el-upload
|
||||||
|
class="cover-upload"
|
||||||
|
:http-request="uploadCover"
|
||||||
|
:before-upload="beforeCoverUpload"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<img v-if="coverPreviewSrc" :src="coverPreviewSrc" class="cover-preview" />
|
||||||
|
<el-icon v-else class="cover-upload-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="formState.id" label="课程状态" prop="status">
|
||||||
|
<el-select v-model="formState.status" placeholder="请选择状态" style="width: 100%">
|
||||||
|
<el-option value="0" label="正常" />
|
||||||
|
<el-option value="1" label="已取消" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="储值卡额度">
|
||||||
|
<el-input-number v-model="formState.storedValueAmount" :min="0" :precision="2" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="课程描述">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入课程描述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modalVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleModalOk">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<GroupCourse[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const searchStatus = ref('')
|
||||||
|
const courseTypes = ref<GroupCourseType[]>([])
|
||||||
|
|
||||||
|
const statusMap: Record<string, { type: string; label: string }> = {
|
||||||
|
'0': { type: 'success', label: '正常' },
|
||||||
|
'1': { type: 'danger', label: '已取消' },
|
||||||
|
'2': { type: 'info', label: '已结束' },
|
||||||
|
'3': { type: '', label: '进行中' },
|
||||||
|
'4': { type: 'warning', label: '超时' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortInfo = reactive({
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc' as 'asc' | 'desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const formState = reactive<GroupCourse>({
|
||||||
|
courseName: '',
|
||||||
|
courseType: undefined,
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
maxMembers: 20,
|
||||||
|
location: '',
|
||||||
|
coverImage: '',
|
||||||
|
description: '',
|
||||||
|
storedValueAmount: 0,
|
||||||
|
status: '0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const duration = ref(60)
|
||||||
|
// @ts-expect-error duration is a frontend-only field
|
||||||
|
formState.duration = duration
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
courseName: [
|
||||||
|
{ required: true, message: '请输入课程名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 100, message: '课程名称长度在 2 到 100 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||||
|
duration: [{ required: true, message: '请输入课程持续时间', trigger: 'blur' }],
|
||||||
|
maxMembers: [{ required: true, message: '请输入最大人数', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverCache = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
|
const loadRowCover = async (coverImage: string | undefined) => {
|
||||||
|
if (!coverImage) return
|
||||||
|
// already loaded
|
||||||
|
if (coverCache[coverImage]) return
|
||||||
|
// if it's a numeric file ID, fetch via preview API
|
||||||
|
if (/^\d+$/.test(coverImage)) {
|
||||||
|
try {
|
||||||
|
const blob: any = await request.get(`/files/${coverImage}/preview`, { responseType: 'blob' })
|
||||||
|
coverCache[coverImage] = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
// leave empty, template will show "No data"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// legacy URL — use directly, but may fail at load time
|
||||||
|
coverCache[coverImage] = coverImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCoverError = (row: GroupCourse) => {
|
||||||
|
if (row.coverImage) {
|
||||||
|
delete coverCache[row.coverImage]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCourseTypes = async () => {
|
||||||
|
try {
|
||||||
|
courseTypes.value = await groupCourseTypeApi.getAll()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCourseTypeName = (typeId: number | undefined): string => {
|
||||||
|
if (typeId === undefined || typeId === null) return '-'
|
||||||
|
const type = courseTypes.value.find(t => t.id === typeId)
|
||||||
|
return type?.typeName || String(typeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await groupCourseApi.getPage({
|
||||||
|
page: pagination.current - 1,
|
||||||
|
size: pagination.pageSize,
|
||||||
|
sortBy: sortInfo.sortBy,
|
||||||
|
sortOrder: sortInfo.sortOrder,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
status: searchStatus.value || undefined,
|
||||||
|
})
|
||||||
|
dataSource.value = res.content
|
||||||
|
pagination.total = Number(res.totalElements) || 0
|
||||||
|
// load cover images for all rows
|
||||||
|
res.content.forEach((row: GroupCourse) => loadRowCover(row.coverImage))
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTableChange = () => fetchData()
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = ({ prop, order }: any) => {
|
||||||
|
sortInfo.sortBy = prop
|
||||||
|
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeCoverUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error('只能上传图片文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5
|
||||||
|
if (!isLt5M) {
|
||||||
|
ElMessage.error('图片大小不能超过 5MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const notBeforeToday = (date: Date) => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime() < today.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadCover = async (options: any) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', options.file)
|
||||||
|
try {
|
||||||
|
const res: any = await request.post('/files/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
// res is the SysFile object (axios interceptor unwrapped response.data)
|
||||||
|
if (res && res.id) {
|
||||||
|
formState.coverImage = String(res.id)
|
||||||
|
// immediately fetch preview for the newly uploaded file
|
||||||
|
loadCoverPreview(String(res.id))
|
||||||
|
}
|
||||||
|
ElMessage.success('封面上传成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('封面上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverPreviewSrc = ref('')
|
||||||
|
|
||||||
|
const loadCoverPreview = async (val: string) => {
|
||||||
|
if (!val) {
|
||||||
|
coverPreviewSrc.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If it looks like a numeric file ID, fetch via preview API
|
||||||
|
if (/^\d+$/.test(val)) {
|
||||||
|
try {
|
||||||
|
const blob: any = await request.get(`/files/${val}/preview`, { responseType: 'blob' })
|
||||||
|
const oldUrl = coverPreviewSrc.value
|
||||||
|
if (oldUrl) URL.revokeObjectURL(oldUrl)
|
||||||
|
coverPreviewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
coverPreviewSrc.value = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: direct URL
|
||||||
|
coverPreviewSrc.value = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch coverImage changes to auto-load preview (e.g. when editing an existing course)
|
||||||
|
watch(() => formState.coverImage, (val) => loadCoverPreview(val || ''), { immediate: true })
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
modalTitle.value = '新增团课'
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
courseName: '',
|
||||||
|
courseType: undefined,
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
maxMembers: 20,
|
||||||
|
location: '',
|
||||||
|
coverImage: '',
|
||||||
|
description: '',
|
||||||
|
storedValueAmount: 0,
|
||||||
|
})
|
||||||
|
formState.duration = 60
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: GroupCourse) => {
|
||||||
|
modalTitle.value = '编辑团课'
|
||||||
|
Object.assign(formState, { ...row })
|
||||||
|
// Calculate duration from startTime and endTime
|
||||||
|
if (row.startTime && row.endTime) {
|
||||||
|
const start = new Date(row.startTime)
|
||||||
|
const end = new Date(row.endTime)
|
||||||
|
formState.duration = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))
|
||||||
|
}
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = async (row: GroupCourse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要取消该团课吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await groupCourseApi.cancel(row.id!)
|
||||||
|
ElMessage.success('取消成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('取消失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: GroupCourse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该团课吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await groupCourseApi.delete(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
// Auto-calculate endTime from startTime + duration
|
||||||
|
if (formState.startTime && formState.duration) {
|
||||||
|
const start = new Date(formState.startTime)
|
||||||
|
start.setMinutes(start.getMinutes() + formState.duration)
|
||||||
|
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())}`
|
||||||
|
}
|
||||||
|
if (formState.id) {
|
||||||
|
await groupCourseApi.update(formState.id, formState)
|
||||||
|
} else {
|
||||||
|
await groupCourseApi.create(formState)
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
modalVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCourseTypes()
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.group-course-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload :deep(.el-upload) {
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload :deep(.el-upload:hover) {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview {
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #8c939d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-no-data {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-card-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="left-section">
|
||||||
|
<el-select
|
||||||
|
v-model="filterType"
|
||||||
|
placeholder="卡类型"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="handleFilter"
|
||||||
|
>
|
||||||
|
<el-option value="TIME_CARD" label="时长卡" />
|
||||||
|
<el-option value="COUNT_CARD" label="次卡" />
|
||||||
|
<el-option value="STORED_VALUE_CARD" label="储值卡" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="filterName"
|
||||||
|
placeholder="搜索卡名称"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@clear="handleFilter"
|
||||||
|
@keyup.enter="handleFilter"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleFilter">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="right-section">
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增会员卡
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="dataSource"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="memberCardId" label="ID" width="80" />
|
||||||
|
<el-table-column prop="memberCardName" label="卡名称" min-width="140" />
|
||||||
|
<el-table-column label="卡类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ cardTypeMap[row.memberCardType as string] || row.memberCardTypeDesc || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
¥{{ row.memberCardPrice ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="卡片详情" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.memberCardType === 'TIME_CARD'">
|
||||||
|
有效期 {{ row.memberCardValidityDays ?? '-' }} 天
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.memberCardType === 'COUNT_CARD'">
|
||||||
|
共 {{ row.memberCardTotalTimes ?? '-' }} 次
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.memberCardType === 'STORED_VALUE_CARD'">
|
||||||
|
面额 ¥{{ row.memberCardAmount ?? '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
-
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.memberCardStatus === 1"
|
||||||
|
active-text="上架"
|
||||||
|
inactive-text="下架"
|
||||||
|
inline-prompt
|
||||||
|
@change="(val: boolean) => handleToggleStatus(row, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.current"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@current-change="fetchData"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="520px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="formRules"
|
||||||
|
label-width="110px"
|
||||||
|
>
|
||||||
|
<el-form-item label="卡名称" prop="memberCardName">
|
||||||
|
<el-input v-model="form.memberCardName" placeholder="请输入卡名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="卡类型" prop="memberCardType">
|
||||||
|
<el-select
|
||||||
|
v-model="form.memberCardType"
|
||||||
|
placeholder="请选择卡类型"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="isEditing"
|
||||||
|
@change="handleTypeChange"
|
||||||
|
>
|
||||||
|
<el-option value="TIME_CARD" label="时长卡" />
|
||||||
|
<el-option value="COUNT_CARD" label="次卡" />
|
||||||
|
<el-option value="STORED_VALUE_CARD" label="储值卡" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格" prop="memberCardPrice">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.memberCardPrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="10"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入价格"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 时长卡专用 -->
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.memberCardType === 'TIME_CARD'"
|
||||||
|
label="有效天数"
|
||||||
|
prop="memberCardValidityDays"
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.memberCardValidityDays"
|
||||||
|
:min="1"
|
||||||
|
:step="30"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入有效天数"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 次卡专用 -->
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.memberCardType === 'COUNT_CARD'"
|
||||||
|
label="总次数"
|
||||||
|
prop="memberCardTotalTimes"
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.memberCardTotalTimes"
|
||||||
|
:min="1"
|
||||||
|
:step="10"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入总次数"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- 储值卡专用 -->
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.memberCardType === 'STORED_VALUE_CARD'"
|
||||||
|
label="面额"
|
||||||
|
prop="memberCardAmount"
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.memberCardAmount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="100"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入面额"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="memberCardStatus">
|
||||||
|
<el-radio-group v-model="form.memberCardStatus">
|
||||||
|
<el-radio :value="1">上架</el-radio>
|
||||||
|
<el-radio :value="0">下架</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</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, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { memberCardApi, type MemberCardType } from '@/api/memberCard.api'
|
||||||
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<MemberCardType[]>([])
|
||||||
|
const filterType = ref('')
|
||||||
|
const filterName = ref('')
|
||||||
|
|
||||||
|
const cardTypeMap: Record<string, string> = {
|
||||||
|
TIME_CARD: '时长卡',
|
||||||
|
COUNT_CARD: '次卡',
|
||||||
|
STORED_VALUE_CARD: '储值卡',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await memberCardApi.list({
|
||||||
|
status: undefined,
|
||||||
|
name: filterName.value || undefined,
|
||||||
|
type: filterType.value || undefined,
|
||||||
|
page: pagination.current - 1,
|
||||||
|
size: pagination.pageSize,
|
||||||
|
})
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
dataSource.value = res
|
||||||
|
pagination.total = res.length
|
||||||
|
} else if (res?.content) {
|
||||||
|
dataSource.value = res.content
|
||||||
|
pagination.total = res.totalElements ?? res.total ?? 0
|
||||||
|
} else if (res?.data) {
|
||||||
|
dataSource.value = res.data
|
||||||
|
pagination.total = res.total ?? res.data?.length ?? 0
|
||||||
|
} else {
|
||||||
|
dataSource.value = []
|
||||||
|
pagination.total = 0
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilter = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 新增/编辑 ==========
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingId = ref<number>()
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
memberCardName: '',
|
||||||
|
memberCardType: 'TIME_CARD' as string,
|
||||||
|
memberCardPrice: undefined as number | undefined,
|
||||||
|
memberCardValidityDays: undefined as number | undefined,
|
||||||
|
memberCardTotalTimes: undefined as number | undefined,
|
||||||
|
memberCardAmount: undefined as number | undefined,
|
||||||
|
memberCardStatus: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive(defaultForm())
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
memberCardName: [{ required: true, message: '请输入卡名称', trigger: 'blur' }],
|
||||||
|
memberCardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
|
||||||
|
memberCardPrice: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTypeChange = () => {
|
||||||
|
// 切换类型时清空不相关字段
|
||||||
|
if (!isEditing.value) {
|
||||||
|
form.memberCardValidityDays = undefined
|
||||||
|
form.memberCardTotalTimes = undefined
|
||||||
|
form.memberCardAmount = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
editingId.value = undefined
|
||||||
|
dialogTitle.value = '新增会员卡'
|
||||||
|
Object.assign(form, defaultForm())
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: MemberCardType) => {
|
||||||
|
isEditing.value = true
|
||||||
|
editingId.value = row.memberCardId
|
||||||
|
dialogTitle.value = '编辑会员卡'
|
||||||
|
form.memberCardName = row.memberCardName || ''
|
||||||
|
form.memberCardType = row.memberCardType || 'TIME_CARD'
|
||||||
|
form.memberCardPrice = row.memberCardPrice
|
||||||
|
form.memberCardValidityDays = row.memberCardValidityDays
|
||||||
|
form.memberCardTotalTimes = row.memberCardTotalTimes
|
||||||
|
form.memberCardAmount = row.memberCardAmount
|
||||||
|
form.memberCardStatus = row.memberCardStatus ?? 1
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
memberCardName: form.memberCardName,
|
||||||
|
memberCardType: form.memberCardType,
|
||||||
|
memberCardPrice: form.memberCardPrice,
|
||||||
|
memberCardStatus: form.memberCardStatus,
|
||||||
|
}
|
||||||
|
if (form.memberCardType === 'TIME_CARD') {
|
||||||
|
body.memberCardValidityDays = form.memberCardValidityDays
|
||||||
|
} else if (form.memberCardType === 'COUNT_CARD') {
|
||||||
|
body.memberCardTotalTimes = form.memberCardTotalTimes
|
||||||
|
} else if (form.memberCardType === 'STORED_VALUE_CARD') {
|
||||||
|
body.memberCardAmount = form.memberCardAmount
|
||||||
|
}
|
||||||
|
if (isEditing.value && editingId.value) {
|
||||||
|
await memberCardApi.update(editingId.value, body)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await memberCardApi.create(body)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 上下架切换 ==========
|
||||||
|
|
||||||
|
const handleToggleStatus = async (row: MemberCardType, val: boolean) => {
|
||||||
|
if (!row.memberCardId) return
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
memberCardName: row.memberCardName,
|
||||||
|
memberCardType: row.memberCardType,
|
||||||
|
memberCardPrice: row.memberCardPrice,
|
||||||
|
memberCardStatus: val ? 1 : 0,
|
||||||
|
}
|
||||||
|
if (row.memberCardType === 'TIME_CARD') {
|
||||||
|
body.memberCardValidityDays = row.memberCardValidityDays
|
||||||
|
} else if (row.memberCardType === 'COUNT_CARD') {
|
||||||
|
body.memberCardTotalTimes = row.memberCardTotalTimes
|
||||||
|
} else if (row.memberCardType === 'STORED_VALUE_CARD') {
|
||||||
|
body.memberCardAmount = row.memberCardAmount
|
||||||
|
}
|
||||||
|
await memberCardApi.update(row.memberCardId, body)
|
||||||
|
ElMessage.success(val ? '已上架' : '已下架')
|
||||||
|
fetchData()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 删除 ==========
|
||||||
|
|
||||||
|
const handleDelete = async (row: MemberCardType) => {
|
||||||
|
if (!row.memberCardId) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除会员卡 "${row.memberCardName}" 吗?`, '确认删除', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await memberCardApi.delete(row.memberCardId)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e !== 'cancel' && e?.message !== 'cancel') {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.member-card-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.left-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
<template>
|
||||||
|
<div class="member-management">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="search-section">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索会员编号/昵称/手机号"
|
||||||
|
clearable
|
||||||
|
style="width: 280px"
|
||||||
|
@clear="handleSearch"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="sort-section">
|
||||||
|
<el-button @click="handleResetSort" :type="sortField === 'createdAt' && sortOrder === 'desc' ? 'primary' : 'default'">
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
最近注册
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="dataSource"
|
||||||
|
style="width: 100%"
|
||||||
|
@sort-change="handleSortChange"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||||
|
<el-table-column label="头像" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-avatar v-if="row.avatar" :src="row.avatar" :size="40" />
|
||||||
|
<el-avatar v-else :size="40">
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
</el-avatar>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="memberNo" label="会员编号" width="140" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.memberNo || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="nickname" label="昵称" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.nickname || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="手机号" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatPhone(row.phone) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="性别" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.genderDesc || genderMap[row.gender as number] || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="注册时间" width="170" prop="createdAt" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="handleDetail(row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" link size="small" @click="handleEditPhone(row)">
|
||||||
|
修改手机号
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.current"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="pagination.total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@current-change="handleTableChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
title="会员详情"
|
||||||
|
width="650px"
|
||||||
|
>
|
||||||
|
<div v-loading="detailLoading" style="min-height: 200px">
|
||||||
|
<el-descriptions v-if="currentDetail" :column="2" border>
|
||||||
|
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="会员编号">{{ currentDetail.memberNo || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="昵称">{{ currentDetail.nickname || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="手机号">{{ formatPhone(currentDetail.phone) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="性别">{{ currentDetail.genderDesc || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="生日">{{ currentDetail.birthday || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="地址" :span="2">{{ currentDetail.address || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="关注状态">
|
||||||
|
<el-tag :type="currentDetail.subscribed ? 'success' : 'info'" size="small">
|
||||||
|
{{ currentDetail.subscribed ? '已关注' : '未关注' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最后登录">{{ formatDateTime(currentDetail.lastLoginAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="注册时间">{{ formatDateTime(currentDetail.createdAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="有效会员卡">{{ currentDetail.activeCardCount ?? 0 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="头像" :span="2">
|
||||||
|
<el-avatar v-if="currentDetail.avatar" :src="currentDetail.avatar" :size="60" />
|
||||||
|
<span v-else>未设置</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 绑定的会员卡记录 -->
|
||||||
|
<div style="margin-top: 20px">
|
||||||
|
<h4 style="margin-bottom: 10px">
|
||||||
|
绑定的会员卡
|
||||||
|
<span v-if="currentDetail?.activeCardCount !== undefined" style="font-weight: normal; font-size: 13px; color: #909399; margin-left: 8px">
|
||||||
|
(有效 {{ currentDetail.activeCardCount }} 张 / 共 {{ (currentDetail.activeCardCount ?? 0) + (currentDetail.inactiveCardCount ?? 0) }} 张)
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<el-table v-if="memberCardRecords.length > 0" :data="memberCardRecords" size="small" border>
|
||||||
|
<el-table-column prop="memberCardName" label="卡名称" min-width="120" />
|
||||||
|
<el-table-column label="卡类型" width="90">
|
||||||
|
<template #default="{ row: rec }">
|
||||||
|
{{ cardTypeMap[rec.memberCardType as string] || rec.memberCardType || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余" width="120">
|
||||||
|
<template #default="{ row: rec }">
|
||||||
|
<template v-if="rec.memberCardType === 'TIME_CARD'">
|
||||||
|
{{ formatDateTime(rec.expireTime) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="rec.memberCardType === 'COUNT_CARD'">
|
||||||
|
{{ rec.remainingTimes ?? 0 }} 次
|
||||||
|
</template>
|
||||||
|
<template v-else-if="rec.memberCardType === 'STORED_VALUE_CARD'">
|
||||||
|
¥{{ rec.remainingAmount ?? 0 }}
|
||||||
|
</template>
|
||||||
|
<template v-else>-</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="购买时间" width="170">
|
||||||
|
<template #default="{ row: rec }">
|
||||||
|
{{ formatDateTime(rec.purchaseTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row: rec }">
|
||||||
|
<el-tag :type="rec.status === 'ACTIVE' ? 'success' : 'info'" size="small">
|
||||||
|
{{ rec.status || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无绑定的会员卡" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="editVisible"
|
||||||
|
title="编辑会员信息"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<div v-loading="editLoading" style="min-height: 200px">
|
||||||
|
<el-form
|
||||||
|
v-if="!editLoading"
|
||||||
|
ref="editFormRef"
|
||||||
|
:model="editForm"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="editForm.nickname" placeholder="请输入昵称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="性别">
|
||||||
|
<el-select v-model="editForm.gender" placeholder="请选择性别" style="width: 100%">
|
||||||
|
<el-option value="MALE" label="男" />
|
||||||
|
<el-option value="FEMALE" label="女" />
|
||||||
|
<el-option value="UNKNOWN" label="未知" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生日">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="editForm.birthday"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择生日"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="地址">
|
||||||
|
<el-input v-model="editForm.address" placeholder="请输入地址" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="头像">
|
||||||
|
<div class="avatar-edit-row">
|
||||||
|
<div class="avatar-upload">
|
||||||
|
<el-upload
|
||||||
|
class="avatar-uploader"
|
||||||
|
:http-request="uploadAvatar"
|
||||||
|
:before-upload="beforeAvatarUpload"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<el-avatar v-if="editForm.avatar" :src="editForm.avatar" :size="80" />
|
||||||
|
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
<span class="avatar-hint">点击上传</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="editForm.avatar"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
@click="handleClearAvatar"
|
||||||
|
>
|
||||||
|
删除头像
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleEditSubmit" :loading="editSubmitting" :disabled="editLoading">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 修改手机号弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="phoneVisible"
|
||||||
|
title="修改手机号"
|
||||||
|
width="400px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="phoneFormRef"
|
||||||
|
:model="phoneForm"
|
||||||
|
:rules="phoneRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="会员ID">
|
||||||
|
<span>{{ currentMemberId }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新手机号" prop="phone">
|
||||||
|
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="phoneVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handlePhoneSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, UserFilled, Plus, Clock } from '@element-plus/icons-vue'
|
||||||
|
import { memberApi, type Member, type MemberDetail, type MemberCardRecord, type UpdateMemberDto } from '@/api/member.api'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<Member[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const sortField = ref<string>('')
|
||||||
|
const sortOrder = ref<string>('')
|
||||||
|
|
||||||
|
const genderMap: Record<number, string> = {
|
||||||
|
0: '未知',
|
||||||
|
1: '男',
|
||||||
|
2: '女',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardTypeMap: Record<string, string> = {
|
||||||
|
TIME_CARD: '时长卡',
|
||||||
|
COUNT_CARD: '次卡',
|
||||||
|
STORED_VALUE_CARD: '储值卡',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化手机号显示,null/空时显示"未绑定" */
|
||||||
|
const formatPhone = (phone: string | undefined | null): string => {
|
||||||
|
if (!phone) return '未绑定'
|
||||||
|
return phone
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 详情相关
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const currentDetail = ref<MemberDetail | null>(null)
|
||||||
|
const memberCardRecords = ref<MemberCardRecord[]>([])
|
||||||
|
|
||||||
|
// 编辑相关
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editLoading = ref(false)
|
||||||
|
const editSubmitting = ref(false)
|
||||||
|
const editFormRef = ref()
|
||||||
|
const editingMemberId = ref<number>()
|
||||||
|
const editForm = reactive<UpdateMemberDto>({
|
||||||
|
nickname: '',
|
||||||
|
gender: undefined,
|
||||||
|
birthday: '',
|
||||||
|
address: '',
|
||||||
|
avatar: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改手机号相关
|
||||||
|
const phoneVisible = ref(false)
|
||||||
|
const phoneFormRef = ref()
|
||||||
|
const currentMemberId = ref<number>()
|
||||||
|
const phoneForm = reactive({
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
const phoneRules = {
|
||||||
|
phone: [
|
||||||
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
pageNum: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortField: sortField.value || 'createdAt',
|
||||||
|
sortOrder: sortOrder.value || 'desc',
|
||||||
|
}
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
params.searchValue = searchKeyword.value
|
||||||
|
}
|
||||||
|
let res: any
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
res = await memberApi.search(params)
|
||||||
|
} else {
|
||||||
|
res = await memberApi.getAll(params)
|
||||||
|
}
|
||||||
|
console.log('会员列表API返回:', res)
|
||||||
|
|
||||||
|
// 兼容多种返回格式:数组、{content/records/list/data: [...]}
|
||||||
|
let list: any[] = []
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
list = res
|
||||||
|
} else if (res?.content) {
|
||||||
|
list = res.content
|
||||||
|
} else if (res?.records) {
|
||||||
|
list = res.records
|
||||||
|
} else if (res?.list) {
|
||||||
|
list = res.list
|
||||||
|
} else if (res?.data && Array.isArray(res.data)) {
|
||||||
|
list = res.data
|
||||||
|
}
|
||||||
|
dataSource.value = list
|
||||||
|
|
||||||
|
// 分页总数
|
||||||
|
if (res?.totalElements !== undefined) {
|
||||||
|
pagination.total = Number(res.totalElements)
|
||||||
|
} else if (res?.total !== undefined) {
|
||||||
|
pagination.total = Number(res.total)
|
||||||
|
} else if (Array.isArray(res)) {
|
||||||
|
pagination.total = res.length
|
||||||
|
} else {
|
||||||
|
pagination.total = list.length
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '加载数据失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
console.error('加载会员列表失败:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTableChange = () => fetchData()
|
||||||
|
const handleSizeChange = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = ({ prop, order }: { prop: string; order: string | null }) => {
|
||||||
|
sortField.value = prop || ''
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetSort = () => {
|
||||||
|
sortField.value = 'createdAt'
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
pagination.current = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetail = async (row: Member) => {
|
||||||
|
console.log('点击详情, row:', row, 'row.id:', row.id, 'type:', typeof row.id)
|
||||||
|
if (!row.id) return
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
currentDetail.value = null
|
||||||
|
memberCardRecords.value = []
|
||||||
|
try {
|
||||||
|
const detail = await memberApi.getDetail(Number(row.id))
|
||||||
|
console.log('会员详情API返回:', detail)
|
||||||
|
currentDetail.value = detail
|
||||||
|
// 获取绑定的会员卡记录
|
||||||
|
try {
|
||||||
|
const records: any = await memberApi.getMemberCardRecords(Number(row.id))
|
||||||
|
memberCardRecords.value = Array.isArray(records) ? records : (records?.content || records?.data || [])
|
||||||
|
} catch {
|
||||||
|
memberCardRecords.value = []
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '获取会员详情失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
console.error('获取会员详情失败:', e)
|
||||||
|
detailVisible.value = false
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: Member) => {
|
||||||
|
console.log('点击编辑, row:', row)
|
||||||
|
if (!row.id) return
|
||||||
|
editingMemberId.value = Number(row.id)
|
||||||
|
editVisible.value = true
|
||||||
|
editLoading.value = true
|
||||||
|
// 先重置表单
|
||||||
|
editForm.nickname = ''
|
||||||
|
editForm.gender = undefined
|
||||||
|
editForm.birthday = ''
|
||||||
|
editForm.address = ''
|
||||||
|
editForm.avatar = ''
|
||||||
|
try {
|
||||||
|
const detail = await memberApi.getDetail(Number(row.id))
|
||||||
|
console.log('编辑-会员详情API返回:', detail)
|
||||||
|
editForm.nickname = detail.nickname || ''
|
||||||
|
// genderDesc 为中文"男"/"女",需转为后端枚举值
|
||||||
|
if (detail.genderDesc === '男') {
|
||||||
|
editForm.gender = 'MALE'
|
||||||
|
} else if (detail.genderDesc === '女') {
|
||||||
|
editForm.gender = 'FEMALE'
|
||||||
|
} else {
|
||||||
|
editForm.gender = 'UNKNOWN'
|
||||||
|
}
|
||||||
|
// birthday 可能是 ISO 格式("2026-01-01T00:00:00.000+00:00"),取前10位
|
||||||
|
if (detail.birthday) {
|
||||||
|
const bd = String(detail.birthday)
|
||||||
|
editForm.birthday = bd.length >= 10 ? bd.substring(0, 10) : bd
|
||||||
|
} else {
|
||||||
|
editForm.birthday = ''
|
||||||
|
}
|
||||||
|
editForm.address = detail.address || ''
|
||||||
|
editForm.avatar = detail.avatar || ''
|
||||||
|
console.log('编辑表单数据:', JSON.parse(JSON.stringify(editForm)))
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '获取会员信息失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
console.error('获取会员信息失败:', e)
|
||||||
|
editVisible.value = false
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSubmit = async () => {
|
||||||
|
if (!editingMemberId.value) return
|
||||||
|
editSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const body: UpdateMemberDto = {
|
||||||
|
nickname: editForm.nickname || undefined,
|
||||||
|
gender: editForm.gender || undefined,
|
||||||
|
birthday: editForm.birthday || undefined,
|
||||||
|
address: editForm.address || undefined,
|
||||||
|
avatar: editForm.avatar || undefined,
|
||||||
|
}
|
||||||
|
await memberApi.update(editingMemberId.value, body)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
editVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '更新失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
} finally {
|
||||||
|
editSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 头像上传 ==========
|
||||||
|
|
||||||
|
const beforeAvatarUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error('只能上传图片文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5
|
||||||
|
if (!isLt5M) {
|
||||||
|
ElMessage.error('图片大小不能超过 5MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadAvatar = async (options: any) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', options.file)
|
||||||
|
try {
|
||||||
|
const res: any = await request.post('/files/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
if (res && res.id) {
|
||||||
|
// 通过预览接口获取图片URL
|
||||||
|
const blob: any = await request.get(`/files/${res.id}/preview`, { responseType: 'blob' })
|
||||||
|
editForm.avatar = URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
ElMessage.success('头像上传成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('头像上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAvatar = () => {
|
||||||
|
editForm.avatar = ''
|
||||||
|
ElMessage.success('头像已清除,保存后生效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 修改手机号 ==========
|
||||||
|
|
||||||
|
const handleEditPhone = (row: Member) => {
|
||||||
|
currentMemberId.value = Number(row.id)
|
||||||
|
phoneForm.phone = ''
|
||||||
|
phoneVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhoneSubmit = async () => {
|
||||||
|
if (!phoneFormRef.value || !currentMemberId.value) return
|
||||||
|
try {
|
||||||
|
await phoneFormRef.value.validate()
|
||||||
|
await memberApi.updatePhone(currentMemberId.value, { phone: phoneForm.phone })
|
||||||
|
ElMessage.success('手机号更新成功')
|
||||||
|
phoneVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel' && error?.message !== 'cancel') {
|
||||||
|
const msg = error?.response?.data?.message || error?.message || '手机号更新失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.member-management {
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-edit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-uploader :deep(.el-upload) {
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-uploader :deep(.el-upload:hover) {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-uploader-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #8c939d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<div class="statistics-dashboard">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="toolbar-label">统计区间:</span>
|
||||||
|
<el-radio-group v-model="selectedRange" size="default" @change="onRangeChange">
|
||||||
|
<el-radio-button value="today">今日</el-radio-button>
|
||||||
|
<el-radio-button value="week">本周</el-radio-button>
|
||||||
|
<el-radio-button value="month">本月</el-radio-button>
|
||||||
|
<el-radio-button value="last30">近30天</el-radio-button>
|
||||||
|
<el-radio-button value="last90">近90天</el-radio-button>
|
||||||
|
<el-radio-button value="year">今年</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Download"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="handleExport"
|
||||||
|
>
|
||||||
|
导出报表
|
||||||
|
</el-button>
|
||||||
|
<span class="generated-hint" v-if="generatedAt">
|
||||||
|
数据更新时间:{{ generatedAt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="dashboard-content">
|
||||||
|
<!-- ========== 总览统计卡片 ========== -->
|
||||||
|
<el-row :gutter="16" class="stat-cards-row">
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-card shadow="hover" class="stat-card stat-card--primary">
|
||||||
|
<div class="stat-card__label">会员总数</div>
|
||||||
|
<div class="stat-card__value">{{ summary?.memberStatistics?.totalMembers ?? '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-card shadow="hover" class="stat-card stat-card--success">
|
||||||
|
<div class="stat-card__label">活跃会员</div>
|
||||||
|
<div class="stat-card__value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-card shadow="hover" class="stat-card stat-card--warning">
|
||||||
|
<div class="stat-card__label">预约总数</div>
|
||||||
|
<div class="stat-card__value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-card shadow="hover" class="stat-card stat-card--info">
|
||||||
|
<div class="stat-card__label">签到总数</div>
|
||||||
|
<div class="stat-card__value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- ========== 会员统计明细 ========== -->
|
||||||
|
<el-card class="section-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="section-title">会员统计</span>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">新增会员</span>
|
||||||
|
<span class="metric-value">{{ summary?.memberStatistics?.newMembers ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">签到会员</span>
|
||||||
|
<span class="metric-value">{{ summary?.memberStatistics?.signInMembers ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">预约会员</span>
|
||||||
|
<span class="metric-value">{{ summary?.memberStatistics?.bookingMembers ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">取消预约</span>
|
||||||
|
<span class="metric-value">{{ summary?.memberStatistics?.cancelBookingMembers ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">活跃会员</span>
|
||||||
|
<span class="metric-value">{{ summary?.memberStatistics?.activeMembers ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- ========== 预约统计明细 ========== -->
|
||||||
|
<el-card class="section-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="section-title">预约统计</span>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">总预约</span>
|
||||||
|
<span class="metric-value">{{ summary?.bookingStatistics?.newBookings ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">已签到</span>
|
||||||
|
<span class="metric-value">{{ summary?.bookingStatistics?.attendBookings ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">已取消</span>
|
||||||
|
<span class="metric-value">{{ summary?.bookingStatistics?.cancelBookings ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">缺席</span>
|
||||||
|
<span class="metric-value">{{ summary?.bookingStatistics?.absentBookings ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="16" :sm="8">
|
||||||
|
<div class="rate-group">
|
||||||
|
<div class="rate-item">
|
||||||
|
<span class="rate-label">出勤率</span>
|
||||||
|
<el-progress
|
||||||
|
:percentage="Number(summary?.bookingStatistics?.attendanceRate ?? 0)"
|
||||||
|
:stroke-width="12"
|
||||||
|
:color="rateColor(summary?.bookingStatistics?.attendanceRate)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rate-item">
|
||||||
|
<span class="rate-label">取消率</span>
|
||||||
|
<el-progress
|
||||||
|
:percentage="Number(summary?.bookingStatistics?.cancelRate ?? 0)"
|
||||||
|
:stroke-width="12"
|
||||||
|
:color="rateColor(summary?.bookingStatistics?.cancelRate, true)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- ========== 签到统计明细 ========== -->
|
||||||
|
<el-card class="section-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="section-title">签到统计</span>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">总签到</span>
|
||||||
|
<span class="metric-value">{{ summary?.signInStatistics?.totalSignIns ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">成功</span>
|
||||||
|
<span class="metric-value">{{ summary?.signInStatistics?.successSignIns ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="8" :sm="4">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">失败</span>
|
||||||
|
<span class="metric-value">{{ summary?.signInStatistics?.failedSignIns ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<div class="rate-group">
|
||||||
|
<div class="rate-item">
|
||||||
|
<span class="rate-label">成功率</span>
|
||||||
|
<el-progress
|
||||||
|
:percentage="Number(summary?.signInStatistics?.successRate ?? 0)"
|
||||||
|
:stroke-width="12"
|
||||||
|
color="#67c23a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="signin-type-row">
|
||||||
|
<el-tag type="primary" size="small">扫码: {{ summary?.signInStatistics?.qrCodeSignIns ?? 0 }}</el-tag>
|
||||||
|
<el-tag type="warning" size="small">手动: {{ summary?.signInStatistics?.manualSignIns ?? 0 }}</el-tag>
|
||||||
|
<el-tag type="success" size="small">人脸: {{ summary?.signInStatistics?.faceSignIns ?? 0 }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { statisticsApi, type StatisticsSummary } from '@/api/statistics.api'
|
||||||
|
|
||||||
|
// ---- 时间区间映射 ----
|
||||||
|
const RANGE_MAP: Record<string, { periodType: string }> = {
|
||||||
|
today: { periodType: 'DAY' },
|
||||||
|
week: { periodType: 'WEEK' },
|
||||||
|
month: { periodType: 'MONTH' },
|
||||||
|
last30: { periodType: 'LAST_30_DAYS' },
|
||||||
|
last90: { periodType: 'LAST_90_DAYS' },
|
||||||
|
year: { periodType: 'YEAR' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const summary = ref<StatisticsSummary>()
|
||||||
|
const generatedAt = ref('')
|
||||||
|
const selectedRange = ref('today')
|
||||||
|
|
||||||
|
const rateColor = (rate: number | undefined, inverse = false) => {
|
||||||
|
const val = Number(rate ?? 0)
|
||||||
|
if (inverse) {
|
||||||
|
if (val <= 20) return '#67c23a'
|
||||||
|
if (val <= 50) return '#e6a23c'
|
||||||
|
return '#f56c6c'
|
||||||
|
}
|
||||||
|
if (val >= 80) return '#67c23a'
|
||||||
|
if (val >= 50) return '#e6a23c'
|
||||||
|
return '#f56c6c'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getQueryParams = () => {
|
||||||
|
const config = RANGE_MAP[selectedRange.value]
|
||||||
|
if (!config) return {}
|
||||||
|
return { periodType: config.periodType }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await statisticsApi.getSummary(getQueryParams())
|
||||||
|
summary.value = data
|
||||||
|
if (data?.generatedAt) {
|
||||||
|
generatedAt.value = new Date(data.generatedAt).toLocaleString()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '获取统计数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRangeChange = () => {
|
||||||
|
fetchSummary()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const blob: any = await statisticsApi.exportStatistics(getQueryParams())
|
||||||
|
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
const label = selectedRange.value === 'today' ? '今日' :
|
||||||
|
selectedRange.value === 'week' ? '本周' :
|
||||||
|
selectedRange.value === 'month' ? '本月' :
|
||||||
|
selectedRange.value === 'last30' ? '近30天' :
|
||||||
|
selectedRange.value === 'last90' ? '近90天' : '今年'
|
||||||
|
link.download = `statistics_${label}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '导出失败')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSummary()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.statistics-dashboard {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-label {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generated-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 统计卡片 ---- */
|
||||||
|
.stat-cards-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card--primary .stat-card__value { color: #409eff; }
|
||||||
|
.stat-card--success .stat-card__value { color: #67c23a; }
|
||||||
|
.stat-card--warning .stat-card__value { color: #e6a23c; }
|
||||||
|
.stat-card--info .stat-card__value { color: #909399; }
|
||||||
|
|
||||||
|
/* ---- 明细分区 ---- */
|
||||||
|
.section-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-item .rate-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-type-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user