更新多个功能

This commit is contained in:
2026-06-29 07:35:07 +08:00
parent fa94f52b53
commit cf7e2560b5
41 changed files with 2388 additions and 37 deletions
@@ -0,0 +1,34 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Mono<BannerEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<BannerEntity> findAllByDeletedAtIsNull();
Flux<BannerEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE banner SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE banner SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -48,6 +48,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt); Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> renewRecurringCourses(LocalDateTime updatedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType); Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
/** /**
@@ -96,6 +100,11 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
// 5. 常态化团课筛选
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序 // 5. 价格排序 / 6. 剩余名额最多排序
@@ -145,6 +154,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
spec = spec.bind("limit", size); spec = spec.bind("limit", size);
spec = spec.bind("offset", offset); spec = spec.bind("offset", offset);
@@ -169,6 +181,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
entity.setCreatedAt(row.get("created_at", LocalDateTime.class)); entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class)); entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class)); entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
return entity; return entity;
}).all(); }).all();
} }
@@ -211,6 +224,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString()); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
@@ -227,6 +244,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one(); return spec.map((row, meta) -> row.get(0, Long.class)).one();
} }
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class Banner extends BaseDomain {
@Schema(description = "背景图URL", example = "https://example.com/banner.jpg")
private String imageUrl;
@Schema(description = "主标题", example = "突破自我")
private String title;
@Schema(description = "副标题", example = "超越极限")
private String subtitle;
@Schema(description = "简介", example = "科学训练 · 遇见更好的自己")
private String description;
@Schema(description = "排序(数值越大越靠前)", example = "10")
private Integer sortOrder;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -60,6 +60,10 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png") @Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Schema(description = "是否常态化团课", example = "true")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -163,4 +167,12 @@ public class GroupCourse extends BaseDomain{
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -40,6 +40,9 @@ public class GroupCourseQueryDto {
@Schema(description = "每页大小", example = "10") @Schema(description = "每页大小", example = "10")
private Integer size = 10; private Integer size = 10;
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
private Boolean isRecurring;
// ===== Getters and Setters ===== // ===== Getters and Setters =====
public String getCourseName() { public String getCourseName() {
@@ -113,4 +116,12 @@ public class GroupCourseQueryDto {
public void setSize(Integer size) { public void setSize(Integer size) {
this.size = size; this.size = size;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -0,0 +1,45 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("banner")
public class BannerEntity extends BaseEntity {
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("description")
private String description;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -62,6 +62,10 @@ public class GroupCourseEntity extends BaseEntity {
@Column("qr_code_path") @Column("qr_code_path")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Column("is_recurring")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -165,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity {
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -0,0 +1,224 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "轮播图管理", description = "轮播图相关操作")
public class BannerHandler {
private final IBannerService bannerService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public BannerHandler(IBannerService bannerService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.bannerService = bannerService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序")
public Mono<ServerResponse> getAllBanners(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("sortOrder");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(bannerService.findAll(sortBy, sortOrder), Banner.class);
}
@Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表")
public Mono<ServerResponse> getAllActiveBanners(ServerRequest request) {
return ServerResponse.ok()
.body(bannerService.findAllActive(), Banner.class);
}
@Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情")
public Mono<ServerResponse> getBannerById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.findById(id)
.flatMap(banner -> ServerResponse.ok().bodyValue(banner))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建轮播图", description = "创建新的轮播图记录")
public Mono<ServerResponse> createBanner(ServerRequest request) {
return request.bodyToMono(Banner.class)
.flatMap(banner -> {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "背景图不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "主标题不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return bannerService.create(banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图创建成功");
response.put("data", r);
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);
});
});
}
@Operation(summary = "更新轮播图", description = "更新指定轮播图信息,需验证管理员密码")
public Mono<ServerResponse> updateBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(Banner.class)
.flatMap(banner -> bannerService.update(id, banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图更新成功");
response.put("data", r);
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);
}));
});
}
@Operation(summary = "删除轮播图", description = "删除指定轮播图(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图删除成功");
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);
});
});
}
@Operation(summary = "启用轮播图", description = "启用指定轮播图")
public Mono<ServerResponse> enableBanner(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图启用成功");
response.put("data", r);
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);
});
}
@Operation(summary = "禁用轮播图", description = "禁用指定轮播图,需验证管理员密码")
public Mono<ServerResponse> disableBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图禁用成功");
response.put("data", r);
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);
});
});
}
}
@@ -177,4 +177,31 @@ public class GroupCourseBookingHandler {
response.put("message", message); response.put("message", message);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
/**
* 扫码签到(二维码扫一扫签到)
* 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席)
*/
@Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席")
public Mono<ServerResponse> qrSignIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID");
}
Long memberId = toLong(body.get("memberId"), "memberId");
return bookingService.qrSignIn(courseId, memberId)
.flatMap(booking -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "签到成功");
response.put("data", booking);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> buildErrorResponse(error.getMessage()));
});
}
} }
@@ -10,6 +10,10 @@ import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
@@ -18,7 +22,10 @@ import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -372,4 +379,44 @@ public class GroupCourseHandler {
}); });
}); });
} }
@Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)")
public Mono<ServerResponse> getCourseQRCode(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("id"));
return groupCourseService.findById(courseId)
.flatMap(course -> {
return Mono.fromCallable(() -> {
String qrContent = "{\"courseId\":" + course.getId()
+ ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}";
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);
byte[] pngBytes = outputStream.toByteArray();
String base64 = Base64.getEncoder().encodeToString(pngBytes);
Map<String, Object> result = new HashMap<>();
result.put("base64", "data:image/png;base64," + base64);
result.put("courseId", course.getId());
result.put("courseName", course.getCourseName());
result.put("width", 300);
result.put("height", 300);
return result;
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
})
.flatMap(result -> ServerResponse.ok().bodyValue(result))
.switchIfEmpty(ServerResponse.notFound().build());
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
} }
@@ -0,0 +1,24 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerRepository {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> save(Banner banner);
Mono<Banner> update(Banner banner);
Mono<Void> deleteById(Long id);
Mono<Banner> updateActiveStatus(Long id, Boolean isActive);
}
@@ -0,0 +1,113 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.BannerDao;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.bannerDao = bannerDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll() {
return bannerDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return bannerDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAllActive() {
return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder"))
.map(this::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return bannerDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<Banner> update(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(banner.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return bannerDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Banner> updateActiveStatus(Long id, Boolean isActive) {
return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private Banner toDomain(BannerEntity entity) {
if (entity == null) return null;
Banner banner = new Banner();
BeanUtil.copyProperties(entity, banner);
return banner;
}
private BannerEntity toEntity(Banner domain) {
if (domain == null) return null;
BannerEntity entity = new BannerEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -138,6 +138,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setStatus(0L); entity.setStatus(0L);
entity.setCurrentMembers(0); entity.setCurrentMembers(0);
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -147,6 +148,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<GroupCourse> update(GroupCourse groupCourse) { public Mono<GroupCourse> update(GroupCourse groupCourse) {
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse); GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return r2dbcEntityTemplate.update(entity) return r2dbcEntityTemplate.update(entity)
.then(findByIdAndDeletedAtIsNull(groupCourse.getId())); .then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
@@ -0,0 +1,48 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 常态化团课自动续期定时任务
*
* 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'),
* 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。
*
* @date 2026-06-29
*/
@Component
public class GroupCourseRecurringScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已结束的常态化团课重置为正常状态,
* 并将上课时间/下课时间各推迟一周
*/
@Scheduled(fixedRate = 60000)
public void renewRecurringCourses() {
logger.debug("定时任务开始检查常态化团课,续期已结束的课程");
groupCourseDao.renewRecurringCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("常态化团课续期完成,更新了 {} 条课程", count);
}
},
error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -0,0 +1,26 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerService {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> create(Banner banner);
Mono<Banner> update(Long id, Banner banner);
Mono<Void> delete(Long id);
Mono<Banner> enable(Long id);
Mono<Banner> disable(Long id);
}
@@ -62,4 +62,14 @@ public interface IGroupCourseBookingService {
* @return 处理的记录数 * @return 处理的记录数
*/ */
Mono<Integer> processAbsentMembers(); Mono<Integer> processAbsentMembers();
/**
* 扫码签到
* 用户扫描团课二维码签到,将预约状态更新为已出席(2)
*
* @param courseId 团课ID
* @param memberId 会员ID
* @return 更新后的预约记录
*/
Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId);
} }
@@ -0,0 +1,99 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class BannerService implements IBannerService {
private static final Logger logger = LoggerFactory.getLogger(BannerService.class);
private final IBannerRepository bannerRepository;
public BannerService(IBannerRepository bannerRepository) {
this.bannerRepository = bannerRepository;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerRepository.findById(id);
}
@Override
public Flux<Banner> findAll() {
return bannerRepository.findAll();
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
return bannerRepository.findAll(sortBy, sortOrder);
}
@Override
public Flux<Banner> findAllActive() {
return bannerRepository.findAllActive();
}
@Override
public Mono<Banner> create(Banner banner) {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
return Mono.error(new RuntimeException("背景图不能为空"));
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
return Mono.error(new RuntimeException("主标题不能为空"));
}
return bannerRepository.save(banner)
.doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle()))
.doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<Banner> update(Long id, Banner banner) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(existing -> {
if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl());
if (banner.getTitle() != null) existing.setTitle(banner.getTitle());
if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle());
if (banner.getDescription() != null) existing.setDescription(banner.getDescription());
if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder());
if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive());
return bannerRepository.update(existing);
})
.doOnSuccess(r -> logger.info("轮播图更新成功 - id={}", id))
.doOnError(error -> logger.error("轮播图更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(banner -> bannerRepository.deleteById(id)
.doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id))
.doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage())));
}
@Override
public Mono<Banner> enable(Long id) {
return bannerRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图启用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Banner> disable(Long id) {
return bannerRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage()));
}
}
@@ -9,6 +9,7 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; 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;
@@ -46,6 +47,7 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final GroupCourseRedisService redisService; private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler; private final BookingSagaHandler bookingSagaHandler;
private final DatabaseClient databaseClient;
// 预约提前时间限制(分钟) // 预约提前时间限制(分钟)
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -56,12 +58,14 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
IGroupCourseRepository courseRepository, IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService, GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher, BookingReminderEventPublisher bookingReminderEventPublisher,
BookingSagaHandler bookingSagaHandler) { BookingSagaHandler bookingSagaHandler,
DatabaseClient databaseClient) {
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.courseRepository = courseRepository; this.courseRepository = courseRepository;
this.redisService = redisService; this.redisService = redisService;
this.bookingReminderEventPublisher = bookingReminderEventPublisher; this.bookingReminderEventPublisher = bookingReminderEventPublisher;
this.bookingSagaHandler = bookingSagaHandler; this.bookingSagaHandler = bookingSagaHandler;
this.databaseClient = databaseClient;
} }
@Override @Override
@@ -347,6 +351,78 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
} }
@Override
public Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId) {
logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId);
return courseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> {
// 校验1:团课状态必须为 0(正常)
Long status = course.getStatus();
if (status == null || status != 0L) {
String msg;
if (status == null) msg = "课程状态异常";
else if (status == 1L) msg = "课程已取消,无法签到";
else if (status == 2L) msg = "课程已结束,无法签到";
else msg = "课程状态不可签到";
return Mono.error(new RuntimeException(msg));
}
// 校验2:用户是否预约了该课程(状态为 0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
// 校验3:预约状态必须为 0
if (!"0".equals(booking.getStatus())) {
String msg;
if ("1".equals(booking.getStatus())) msg = "预约已取消";
else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到";
else msg = "预约状态异常";
return Mono.error(new RuntimeException(msg));
}
// 更新预约状态为 2(已出席)
return bookingRepository.updateStatus(booking.getId(), "2")
.then(Mono.defer(() -> {
// 同步写入签到记录,供仪表盘统计
LocalDateTime now = LocalDateTime.now();
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 先检查今天是否已有成功签到记录,避免重复
return databaseClient.sql(
"SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1")
.bind("memberId", memberId)
.bind("startTime", todayStart)
.bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.flatMap(existing -> {
// 已有签到记录,不重复插入
return bookingRepository.findById(booking.getId());
})
.switchIfEmpty(
// 无今日签到记录,插入一条
databaseClient.sql(
"INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " +
"VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)")
.bind("memberId", memberId)
.bind("memberCardId", booking.getMemberCardRecordId())
.bind("signInTime", now)
.fetch()
.rowsUpdated()
.then(bookingRepository.findById(booking.getId()))
);
}));
});
})
.doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}",
booking.getId(), courseId, memberId))
.doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage()));
}
@Override @Override
public Mono<Integer> processAbsentMembers() { public Mono<Integer> processAbsentMembers() {
logger.info("开始处理已开始课程但未到场会员的预约记录"); logger.info("开始处理已开始课程但未到场会员的预约记录");
@@ -347,6 +347,9 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getQrCodePath() != null) { if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath()); existing.setQrCodePath(groupCourse.getQrCodePath());
} }
if (groupCourse.getIsRecurring() != null) {
existing.setIsRecurring(groupCourse.getIsRecurring());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -0,0 +1,54 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付记录响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "支付记录")
public class PaymentRecordResponse {
@Schema(description = "订单ID")
private Long id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "用户编号(会员ID)")
private Long memberId;
@Schema(description = "支付方式(ALIPAY/WECHAT)")
private String tradeType;
@Schema(description = "购买内容(商品描述)")
private String goodsDesc;
@Schema(description = "订单类型(MEMBER_CARD/GROUP_COURSE/GOODS)")
private String orderType;
@Schema(description = "支付金额(元)")
private BigDecimal transAmt;
@Schema(description = "支付状态(PENDING/SUCCESS/FAIL/CLOSED)")
private String payStatus;
@Schema(description = "支付时间")
private String payTime;
@Schema(description = "汇付流水号")
private String hfSeqId;
@Schema(description = "创建时间")
private String createdAt;
}
@@ -0,0 +1,47 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 营业额统计数据
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "营业额统计数据")
public class RevenueStatistics {
@Schema(description = "今日收入(元)")
private BigDecimal todayIncome;
@Schema(description = "今日退款(元)")
private BigDecimal todayRefund;
@Schema(description = "今日订单数")
private Long todayOrderCount;
@Schema(description = "当月收入(元)")
private BigDecimal monthIncome;
@Schema(description = "当月退款(元)")
private BigDecimal monthRefund;
@Schema(description = "当月订单数")
private Long monthOrderCount;
@Schema(description = "当年收入(元)")
private BigDecimal yearIncome;
@Schema(description = "当年退款(元)")
private BigDecimal yearRefund;
@Schema(description = "当年订单数")
private Long yearOrderCount;
}
@@ -0,0 +1,74 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.ApiResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
/**
* 支付营业数据 Handler
*
* @author system
* @date 2026-06-29
*/
@Slf4j
@Component
@Tag(name = "支付营业数据", description = "营业额统计与支付记录查询")
public class PaymentRevenueHandler {
private final PaymentService paymentService;
public PaymentRevenueHandler(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "获取营业额统计", description = "获取今日、当月、当年的收入和退款情况")
public Mono<ServerResponse> getRevenueStatistics(ServerRequest request) {
log.info("[Revenue] 查询营业额统计");
return paymentService.getRevenueStatistics()
.flatMap(stats -> ServerResponse.ok()
.bodyValue(ApiResponse.success(stats)))
.onErrorResume(e -> {
log.error("[Revenue] 查询营业额统计失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询营业额统计失败: " + e.getMessage()));
});
}
@Operation(summary = "查询支付记录", description = "分页查询用户支付记录,支持按会员ID、支付状态、支付方式筛选")
public Mono<ServerResponse> getPaymentRecords(ServerRequest request) {
String memberIdStr = request.queryParam("memberId").orElse(null);
Long memberId = null;
if (memberIdStr != null && !memberIdStr.isEmpty()) {
try {
memberId = Long.parseLong(memberIdStr);
} catch (NumberFormatException e) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error("会员ID格式不正确"));
}
}
String payStatus = request.queryParam("payStatus").orElse(null);
String tradeType = request.queryParam("tradeType").orElse(null);
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
int pageSize = Integer.parseInt(request.queryParam("pageSize").orElse("20"));
log.info("[Revenue] 查询支付记录: memberId={}, payStatus={}, tradeType={}, page={}, pageSize={}",
memberId, payStatus, tradeType, page, pageSize);
return paymentService.getPaymentRecords(memberId, payStatus, tradeType, page, pageSize)
.flatMap(result -> ServerResponse.ok()
.bodyValue(ApiResponse.success(result)))
.onErrorResume(e -> {
log.error("[Revenue] 查询支付记录失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询支付记录失败: " + e.getMessage()));
});
}
}
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository;
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;
@Repository @Repository
@@ -38,4 +39,24 @@ public interface PaymentOrderRepository extends R2dbcRepository<PaymentOrder, Lo
Flux<PaymentOrder> findAllByDeletedAtIsNull(); Flux<PaymentOrder> findAllByDeletedAtIsNull();
Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId); Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId);
// ===== 营业额统计 =====
/** 统计指定时间范围内成功支付的金额总和 */
@Query("SELECT COALESCE(SUM(trans_amt), 0) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<BigDecimal> sumSuccessAmount(LocalDateTime startTime, LocalDateTime endTime);
/** 统计指定时间范围内的成功订单数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<Long> countSuccessOrders(LocalDateTime startTime, LocalDateTime endTime);
// ===== 支付记录分页查询 =====
/** 按条件分页查询支付记录 */
@Query("SELECT * FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
Flux<PaymentOrder> findPaymentRecords(Long memberId, String payStatus, String tradeType, int limit, int offset);
/** 按条件统计支付记录总数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL")
Mono<Long> countPaymentRecords(Long memberId, String payStatus, String tradeType);
} }
@@ -1,7 +1,9 @@
package cn.novalon.gym.manage.payment.service; package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -30,4 +32,10 @@ public interface PaymentService {
Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType); Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType);
Mono<Boolean> closeOrder(Long memberId, String orderId); Mono<Boolean> closeOrder(Long memberId, String orderId);
/** 获取营业额统计(今日/当月/当年) */
Mono<RevenueStatistics> getRevenueStatistics();
/** 分页查询支付记录 */
Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize);
} }
@@ -3,7 +3,9 @@ package cn.novalon.gym.manage.payment.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.payment.config.HuifuProperties; import cn.novalon.gym.manage.payment.config.HuifuProperties;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import cn.novalon.gym.manage.payment.entity.PaymentOrder; import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository; import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository;
import cn.novalon.gym.manage.payment.service.PaymentNotifyService; import cn.novalon.gym.manage.payment.service.PaymentNotifyService;
@@ -934,4 +936,76 @@ public class PaymentServiceImpl implements PaymentService {
}) })
.switchIfEmpty(Mono.just(false)); .switchIfEmpty(Mono.just(false));
} }
@Override
public Mono<RevenueStatistics> getRevenueStatistics() {
LocalDateTime now = LocalDateTime.now();
// 今日
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 当月
LocalDateTime monthStart = now.toLocalDate().withDayOfMonth(1).atStartOfDay();
LocalDateTime monthEnd = monthStart.plusMonths(1);
// 当年
LocalDateTime yearStart = now.toLocalDate().withDayOfYear(1).atStartOfDay();
LocalDateTime yearEnd = yearStart.plusYears(1);
Mono<BigDecimal> todayIncomeMono = paymentOrderRepository.sumSuccessAmount(todayStart, todayEnd);
Mono<Long> todayCountMono = paymentOrderRepository.countSuccessOrders(todayStart, todayEnd);
Mono<BigDecimal> monthIncomeMono = paymentOrderRepository.sumSuccessAmount(monthStart, monthEnd);
Mono<Long> monthCountMono = paymentOrderRepository.countSuccessOrders(monthStart, monthEnd);
Mono<BigDecimal> yearIncomeMono = paymentOrderRepository.sumSuccessAmount(yearStart, yearEnd);
Mono<Long> yearCountMono = paymentOrderRepository.countSuccessOrders(yearStart, yearEnd);
return Mono.zip(todayIncomeMono, todayCountMono, monthIncomeMono, monthCountMono, yearIncomeMono, yearCountMono)
.map(tuple -> RevenueStatistics.builder()
.todayIncome(tuple.getT1() != null ? tuple.getT1() : BigDecimal.ZERO)
.todayRefund(BigDecimal.ZERO) // 退款功能暂未实现
.todayOrderCount(tuple.getT2() != null ? tuple.getT2() : 0L)
.monthIncome(tuple.getT3() != null ? tuple.getT3() : BigDecimal.ZERO)
.monthRefund(BigDecimal.ZERO) // 退款功能暂未实现
.monthOrderCount(tuple.getT4() != null ? tuple.getT4() : 0L)
.yearIncome(tuple.getT5() != null ? tuple.getT5() : BigDecimal.ZERO)
.yearRefund(BigDecimal.ZERO) // 退款功能暂未实现
.yearOrderCount(tuple.getT6() != null ? tuple.getT6() : 0L)
.build());
}
@Override
public Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize) {
int offset = (page - 1) * pageSize;
Mono<List<PaymentRecordResponse>> recordsMono = paymentOrderRepository
.findPaymentRecords(memberId, payStatus, tradeType, pageSize, offset)
.map(order -> PaymentRecordResponse.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.memberId(order.getMemberId())
.tradeType(order.getTradeType())
.goodsDesc(order.getGoodsDesc())
.orderType(order.getOrderType())
.transAmt(order.getTransAmt())
.payStatus(order.getPayStatus())
.payTime(order.getPayTime() != null
? order.getPayTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.hfSeqId(order.getHfSeqId())
.createdAt(order.getCreatedAt() != null
? order.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.build())
.collectList();
Mono<Long> totalMono = paymentOrderRepository.countPaymentRecords(memberId, payStatus, tradeType);
return Mono.zip(recordsMono, totalMono)
.map(tuple -> {
Map<String, Object> result = new HashMap<>();
result.put("records", tuple.getT1());
result.put("total", tuple.getT2());
result.put("page", page);
result.put("pageSize", pageSize);
return result;
});
}
} }
@@ -7,6 +7,7 @@ import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler; import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.BannerHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler; import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
@@ -20,6 +21,7 @@ import cn.novalon.gym.manage.member.handler.WechatAuthHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.payment.handler.PaymentRevenueHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -80,10 +82,12 @@ public class SystemRouter {
GroupCourseRecommendHandler groupCourseRecommendHandler, GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler, GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler, CourseLabelHandler courseLabelHandler,
BannerHandler bannerHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler, DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler, PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler, PaymentHandler paymentHandler,
PaymentRevenueHandler paymentRevenueHandler,
CommonUploadHandler commonUploadHandler) { CommonUploadHandler commonUploadHandler) {
return route() return route()
@@ -348,7 +352,19 @@ public class SystemRouter {
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation) .POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation) .POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ========== 轮播图路由 ==========
.GET("/api/banner/list", bannerHandler::getAllBanners)
.GET("/api/banner/active", bannerHandler::getAllActiveBanners)
.GET("/api/banner/{id}", bannerHandler::getBannerById)
.POST("/api/banner", bannerHandler::createBanner)
.PUT("/api/banner/{id}", bannerHandler::updateBanner)
.DELETE("/api/banner/{id}", bannerHandler::deleteBanner)
.POST("/api/banner/{id}/enable", bannerHandler::enableBanner)
.POST("/api/banner/{id}/disable", bannerHandler::disableBanner)
// ===== 团课课程管理(需要放在具体路由之后)===== // ===== 团课课程管理(需要放在具体路由之后)=====
.GET("/api/groupCourse/{id}/qrcode", groupCourseHandler::getCourseQRCode)
.POST("/api/groupCourse/{courseId}/qrsignin", groupCourseBookingHandler::qrSignIn)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById) .GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById) .GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) .POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
@@ -404,6 +420,10 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment) .POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder) .POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ===== 支付营业数据 =====
.GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics)
.GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords)
.build(); .build();
} }
} }
@@ -15,7 +15,7 @@ spring:
url: jdbc:postgresql://localhost:55432/manage_system url: jdbc:postgresql://localhost:55432/manage_system
user: novalon user: novalon
password: novalon123 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
@@ -0,0 +1,7 @@
-- ============================================
-- 团课新增常态化标记
-- ============================================
ALTER TABLE group_course ADD COLUMN IF NOT EXISTS is_recurring BOOLEAN DEFAULT FALSE;
COMMENT ON COLUMN group_course.is_recurring IS '是否常态化团课:TRUE-是,FALSE-否(默认)';
@@ -0,0 +1,36 @@
-- ============================================
-- 轮播图表
-- ============================================
CREATE TABLE IF NOT EXISTS banner (
id BIGSERIAL PRIMARY KEY,
image_url TEXT NOT NULL,
title VARCHAR(100) NOT NULL,
subtitle VARCHAR(100),
description TEXT,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_banner_sort_order ON banner(sort_order);
CREATE INDEX IF NOT EXISTS idx_banner_is_active ON banner(is_active);
CREATE INDEX IF NOT EXISTS idx_banner_deleted_at ON banner(deleted_at);
COMMENT ON TABLE banner IS '轮播图表';
COMMENT ON COLUMN banner.id IS '主键ID';
COMMENT ON COLUMN banner.image_url IS '背景图URL';
COMMENT ON COLUMN banner.title IS '主标题';
COMMENT ON COLUMN banner.subtitle IS '副标题';
COMMENT ON COLUMN banner.description IS '简介';
COMMENT ON COLUMN banner.sort_order IS '排序(数值越大越靠前)';
COMMENT ON COLUMN banner.is_active IS '是否启用';
COMMENT ON COLUMN banner.create_by IS '创建人';
COMMENT ON COLUMN banner.update_by IS '更新人';
COMMENT ON COLUMN banner.created_at IS '创建时间';
COMMENT ON COLUMN banner.updated_at IS '更新时间';
COMMENT ON COLUMN banner.deleted_at IS '删除时间(软删除)';
+34
View File
@@ -0,0 +1,34 @@
import { get, post, put, del } from '@/api/request'
import type { AxiosRequestConfig } from 'axios'
export function getAllBanners(params?: any) {
return get('/api/banner/list', params)
}
export function getActiveBanners() {
return get('/api/banner/active')
}
export function getBannerById(id: number) {
return get(`/api/banner/${id}`)
}
export function createBanner(data: any) {
return post('/api/banner', data)
}
export function updateBanner(id: number, data: any, config?: AxiosRequestConfig) {
return put(`/api/banner/${id}`, data, config)
}
export function deleteBanner(id: number, config?: AxiosRequestConfig) {
return del(`/api/banner/${id}`, config)
}
export function enableBanner(id: number) {
return post(`/api/banner/${id}/enable`)
}
export function disableBanner(id: number, config?: AxiosRequestConfig) {
return post(`/api/banner/${id}/disable`, null, config)
}
+22
View File
@@ -0,0 +1,22 @@
import { get, put } from '@/api/request'
/**
* 获取所有配置列表
*/
export function getAllConfigs(params?: any) {
return get('/api/config', params)
}
/**
* 根据配置键获取配置
*/
export function getConfigByKey(configKey: string) {
return get(`/api/config/key/${configKey}`)
}
/**
* 更新配置
*/
export function updateConfig(id: number, data: any) {
return put(`/api/config/${id}`, data)
}
@@ -53,3 +53,7 @@ export function cancelGroupCourse(id: number) {
export function searchGroupCourses(data: any) { export function searchGroupCourses(data: any) {
return post('/api/groupCourse/search', data) return post('/api/groupCourse/search', data)
} }
export function getGroupCourseQRCode(id: number) {
return get(`/api/groupCourse/${id}/qrcode`)
}
@@ -0,0 +1,9 @@
import { get } from '@/api/request'
export function getRevenueStatistics() {
return get('/api/payment/revenue/statistics')
}
export function getPaymentRecords(params?: any) {
return get('/api/payment/revenue/records', params)
}
@@ -55,12 +55,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/groupCourseRecommend/index.vue'), component: () => import('@/views/groupCourseRecommend/index.vue'),
meta: { title: '推荐团课', icon: 'Star' }, meta: { title: '推荐团课', icon: 'Star' },
}, },
{
path: 'banner',
name: 'Banner',
component: () => import('@/views/banner/index.vue'),
meta: { title: '轮播图', icon: 'Picture' },
},
{ {
path: 'datacount', path: 'datacount',
name: 'DataCount', name: 'DataCount',
component: () => import('@/views/datacount/index.vue'), component: () => import('@/views/datacount/index.vue'),
meta: { title: '数据报表', icon: 'DataAnalysis' }, meta: { title: '数据报表', icon: 'DataAnalysis' },
}, },
{
path: 'payment/revenue',
name: 'PaymentRevenue',
component: () => import('@/views/payment/revenue/index.vue'),
meta: { title: '营收详情', icon: 'Money' },
},
{ {
path: 'operationLog', path: 'operationLog',
name: 'OperationLog', name: 'OperationLog',
+10
View File
@@ -39,6 +39,11 @@ const allMenus: MenuItem[] = [
{ path: '/groupCourse/recommend', name: 'GroupCourseRecommend', meta: { title: '推荐管理', icon: 'Star', permission: 'business:groupCourseRecommend:view' } }, { path: '/groupCourse/recommend', name: 'GroupCourseRecommend', meta: { title: '推荐管理', icon: 'Star', permission: 'business:groupCourseRecommend:view' } },
], ],
}, },
{
path: '/banner',
name: 'Banner',
meta: { title: '轮播图', icon: 'Picture' },
},
{ {
path: '/checkIn', path: '/checkIn',
name: 'CheckIn', name: 'CheckIn',
@@ -49,6 +54,11 @@ const allMenus: MenuItem[] = [
name: 'DataCount', name: 'DataCount',
meta: { title: '数据报表', icon: 'DataAnalysis', permission: 'business:dataCount:view' }, meta: { title: '数据报表', icon: 'DataAnalysis', permission: 'business:dataCount:view' },
}, },
{
path: '/payment/revenue',
name: 'PaymentRevenue',
meta: { title: '营收详情', icon: 'Money', permission: 'business:payment:view' },
},
{ {
path: '/operationLog', path: '/operationLog',
name: 'OperationLog', name: 'OperationLog',
+496
View File
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
getAllBanners,
createBanner,
updateBanner,
deleteBanner,
enableBanner,
disableBanner,
} from '@/api/modules/banner'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Picture, Delete } from '@element-plus/icons-vue'
// ---- Types ----
interface BannerItem {
id: number
imageUrl: string
title: string
subtitle: string
description: string
sortOrder: number
isActive: boolean
createdAt: string
updatedAt: string
}
// ---- State ----
const loading = ref(false)
const list = ref<BannerItem[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const saving = ref(false)
// 图片上传
const uploading = ref(false)
const form = reactive({
imageUrl: '',
title: '',
subtitle: '',
description: '',
sortOrder: 0,
isActive: true,
adminPassword: '',
})
// ---- Preview ----
const previewVisible = ref(false)
const previewBanner = ref<BannerItem | null>(null)
// ---- Computed ----
const previewStyle = computed(() => {
if (!previewBanner.value) return {}
return {
backgroundImage: `url(${previewBanner.value.imageUrl})`,
}
})
// ---- Methods ----
async function fetchData() {
loading.value = true
try {
const res = await getAllBanners({ sortBy: 'sortOrder', sortOrder: 'desc' })
list.value = Array.isArray(res) ? res : (res?.data || [])
} catch {
list.value = []
} finally {
loading.value = false
}
}
async function handleDelete(row: BannerItem) {
try {
await ElMessageBox.confirm('确定删除该轮播图?', '提示', { type: 'warning' })
} catch {
return
}
try {
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认删除', '验证密码', {
inputType: 'password',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
if (!pwd) {
ElMessage.warning('请输入验证密码')
return
}
await deleteBanner(row.id, { params: { adminPassword: pwd } })
ElMessage.success('删除成功')
fetchData()
} catch {
ElMessage.warning('操作已取消')
}
}
function openCreate() {
isEdit.value = false
editId.value = null
Object.assign(form, {
imageUrl: '',
title: '',
subtitle: '',
description: '',
sortOrder: 0,
isActive: true,
adminPassword: '',
})
dialogVisible.value = true
}
function openEdit(row: BannerItem) {
isEdit.value = true
editId.value = row.id
Object.assign(form, {
imageUrl: row.imageUrl,
title: row.title || '',
subtitle: row.subtitle || '',
description: row.description || '',
sortOrder: row.sortOrder,
isActive: row.isActive,
adminPassword: '',
})
dialogVisible.value = true
}
async function handleSave() {
if (!form.imageUrl.trim()) {
ElMessage.warning('请上传背景图')
return
}
if (!form.title.trim()) {
ElMessage.warning('请输入主标题')
return
}
saving.value = true
try {
const { adminPassword, ...payload } = form
if (isEdit.value) {
if (!adminPassword) {
ElMessage.warning('请输入验证密码')
saving.value = false
return
}
await updateBanner(editId.value!, payload, { params: { adminPassword } })
ElMessage.success('更新成功')
} else {
await createBanner(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch {
// handled by interceptor
} finally {
saving.value = false
}
}
async function toggleStatus(row: BannerItem) {
try {
if (row.isActive) {
const { value: pwd } = await ElMessageBox.prompt('请输入管理员密码以确认禁用', '验证密码', {
inputType: 'password',
confirmButtonText: '确认禁用',
cancelButtonText: '取消',
})
if (!pwd) {
ElMessage.warning('请输入验证密码')
return
}
await disableBanner(row.id, { params: { adminPassword: pwd } })
ElMessage.success('已禁用')
} else {
await enableBanner(row.id)
ElMessage.success('已启用')
}
fetchData()
} catch {
ElMessage.warning('操作已取消')
}
}
function openPreview(row: BannerItem) {
previewBanner.value = row
previewVisible.value = true
}
// 模拟上传(实际接入后端 OSS 上传)
async function handleUpload() {
// 创建一个临时 file input
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async (e: any) => {
const file = e.target?.files?.[0]
if (!file) return
uploading.value = true
try {
// 临时方案:使用 FileReader 转 base64 预览(正式环境需替换为 OSS 上传)
const reader = new FileReader()
reader.onload = (ev) => {
form.imageUrl = ev.target?.result as string
ElMessage.success('图片已加载(正式环境请接入 OSS 上传)')
}
reader.readAsDataURL(file)
} catch {
ElMessage.error('图片加载失败')
} finally {
uploading.value = false
}
}
input.click()
}
function removeImage() {
form.imageUrl = ''
}
onMounted(() => fetchData())
</script>
<template>
<div class="banner-page">
<h2 style="margin-bottom:16px">轮播图管理</h2>
<el-card class="filter-card" shadow="never">
<div class="filter-row">
<span style="color:#909399;font-size:13px"> {{ list.length }} 条轮播图</span>
<el-button type="primary" @click="openCreate">新增轮播图</el-button>
</div>
</el-card>
<!-- Table -->
<el-card shadow="never">
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="背景图" width="120" align="center">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-src-list="[row.imageUrl]"
fit="cover"
style="width:80px;height:50px;border-radius:6px"
/>
<span v-else style="color:#C0C4CC"></span>
</template>
</el-table-column>
<el-table-column prop="title" label="主标题" width="140" show-overflow-tooltip />
<el-table-column prop="subtitle" label="副标题" width="140" show-overflow-tooltip />
<el-table-column prop="description" label="简介" min-width="200" show-overflow-tooltip />
<el-table-column label="排序" width="80" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.sortOrder }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openPreview(row)">
<el-icon><Picture /></el-icon> 预览
</el-button>
<el-button type="primary" link @click="openEdit(row)">编辑</el-button>
<el-button
:type="row.isActive ? 'warning' : 'success'"
link
@click="toggleStatus(row)"
>
{{ row.isActive ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="list.length === 0 && !loading" class="empty-hint">
暂无轮播图数据点击"新增轮播图"开始创建
</div>
</el-card>
<!-- Create / Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑轮播图' : '新增轮播图'"
width="600px"
:close-on-click-modal="false"
@closed="form.adminPassword = ''"
>
<el-form :model="form" label-width="100px">
<!-- 背景图上传 -->
<el-form-item label="背景图" required>
<div class="image-upload">
<div v-if="form.imageUrl" class="image-upload__preview">
<el-image :src="form.imageUrl" fit="cover" style="width:240px;height:135px;border-radius:8px" />
<div class="image-upload__overlay">
<el-button type="danger" :icon="Delete" circle size="small" @click="removeImage" />
</div>
</div>
<div v-else class="image-upload__trigger" @click="handleUpload">
<el-icon :size="32" color="#C0C4CC"><Picture /></el-icon>
<span style="color:#909399;font-size:12px;margin-top:4px">点击上传</span>
</div>
<div v-if="uploading" style="margin-left:12px">
<span style="color:#909399;font-size:12px">加载中...</span>
</div>
</div>
</el-form-item>
<el-form-item label="主标题" required>
<el-input v-model="form.title" placeholder="如:突破自我" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" placeholder="如:超越极限" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="如:科学训练 · 遇见更好的自己"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
<span style="margin-left:8px;color:#909399;font-size:12px">数值越大越靠前</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.isActive" />
</el-form-item>
<el-form-item v-if="isEdit" label="验证密码" required>
<el-input v-model="form.adminPassword" type="password" placeholder="请输入验证密码" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template>
</el-dialog>
<!-- Preview Dialog -->
<el-dialog
v-model="previewVisible"
title="轮播图预览"
width="480px"
:close-on-click-modal="true"
>
<div v-if="previewBanner" class="banner-preview">
<div class="banner-preview__card" :style="previewStyle">
<div class="banner-preview__overlay"></div>
<div class="banner-preview__text">
<span class="banner-preview__title">{{ previewBanner.title }}</span>
<span class="banner-preview__subtitle">{{ previewBanner.subtitle }}</span>
<span class="banner-preview__desc">{{ previewBanner.description }}</span>
</div>
</div>
<div class="banner-preview__info">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="主标题">{{ previewBanner.title }}</el-descriptions-item>
<el-descriptions-item label="副标题">{{ previewBanner.subtitle }}</el-descriptions-item>
<el-descriptions-item label="简介">{{ previewBanner.description }}</el-descriptions-item>
<el-descriptions-item label="排序">{{ previewBanner.sortOrder }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="previewBanner.isActive ? 'success' : 'info'" size="small">
{{ previewBanner.isActive ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.banner-page {
padding: 0;
}
.filter-card {
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.empty-hint {
color: #909399;
font-size: 13px;
text-align: center;
padding: 40px;
}
/* 图片上传 */
.image-upload {
display: flex;
align-items: center;
gap: 12px;
}
.image-upload__preview {
position: relative;
}
.image-upload__overlay {
position: absolute;
top: -8px;
right: -8px;
}
.image-upload__trigger {
width: 240px;
height: 135px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
}
.image-upload__trigger:hover {
border-color: #409eff;
}
/* 预览卡片 */
.banner-preview {
display: flex;
flex-direction: column;
gap: 16px;
}
.banner-preview__card {
width: 100%;
height: 240px;
background-size: cover;
background-position: center;
border-radius: 12px;
position: relative;
overflow: hidden;
}
.banner-preview__overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.25) 100%);
}
.banner-preview__text {
position: absolute;
left: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
flex-direction: column;
}
.banner-preview__title {
font-size: 28px;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-preview__subtitle {
font-size: 32px;
font-weight: 800;
color: #e0f0fa;
margin-bottom: 12px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-preview__desc {
font-size: 15px;
color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.banner-preview__info {
padding: 0 4px;
}
</style>
+41 -11
View File
@@ -6,12 +6,14 @@ import {
getBookingStatistics, getBookingStatistics,
getSignInStatistics, getSignInStatistics,
} from '@/api/modules/datacount' } from '@/api/modules/datacount'
import { getRevenueStatistics } from '@/api/modules/payment'
import dayjs from 'dayjs' import dayjs from 'dayjs'
interface StatCard { interface StatCard {
label: string label: string
value: number | string value: number | string
color: string color: string
suffix?: string
} }
const loading = ref(false) const loading = ref(false)
@@ -20,6 +22,7 @@ const statCards = ref<StatCard[]>([
{ label: '今日新增', value: 0, color: '#52c41a' }, { label: '今日新增', value: 0, color: '#52c41a' },
{ label: '今日签到', value: 0, color: '#722ed1' }, { label: '今日签到', value: 0, color: '#722ed1' },
{ label: '今日预约', value: 0, color: '#faad14' }, { label: '今日预约', value: 0, color: '#faad14' },
{ label: '今日营业额', value: 0, color: '#e6a23c', suffix: '元' },
]) ])
const memberChartRef = ref() const memberChartRef = ref()
@@ -92,7 +95,7 @@ async function fetchData() {
}).catch(() => {}), }).catch(() => {}),
getBookingStatistics(range).then((res: any) => { getBookingStatistics(range).then((res: any) => {
bookings[i] = res?.newBookings ?? 0 bookingData[i] = res?.newBookings ?? 0
cancelData[i] = res?.cancelBookings ?? 0 cancelData[i] = res?.cancelBookings ?? 0
if (i === todayIdx) { if (i === todayIdx) {
statCards.value[3].value = res?.newBookings ?? 0 statCards.value[3].value = res?.newBookings ?? 0
@@ -107,6 +110,17 @@ async function fetchData() {
}).catch(() => {}), }).catch(() => {}),
) )
} }
// 营业额统计
promises.push(
getRevenueStatistics().then((res: any) => {
const data = res?.data
if (data) {
statCards.value[4].value = (data.todayIncome ?? 0).toFixed(2)
}
}).catch(() => {}),
)
await Promise.allSettled(promises) await Promise.allSettled(promises)
} catch { } catch {
// fallback: empty data // fallback: empty data
@@ -192,19 +206,24 @@ onMounted(() => {
<div v-loading="loading"> <div v-loading="loading">
<h2 style="margin-bottom: 16px">仪表盘</h2> <h2 style="margin-bottom: 16px">仪表盘</h2>
<el-row :gutter="16" style="margin-bottom: 16px"> <!-- 核心指标卡片 -->
<el-col :span="6" v-for="card in statCards" :key="card.label"> <div class="stat-cards" style="margin-bottom: 16px">
<el-card shadow="hover"> <el-card v-for="card in statCards" :key="card.label" shadow="hover" class="stat-card">
<div style="text-align: center"> <div style="text-align: center">
<div style="font-size: 28px; font-weight: bold" :style="{ color: card.color }"> <div style="font-size: 24px; font-weight: bold" :style="{ color: card.color }">
<template v-if="card.suffix">
¥{{ card.value }}{{ card.suffix }}
</template>
<template v-else>
{{ typeof card.value === 'number' ? card.value.toLocaleString() : card.value }} {{ typeof card.value === 'number' ? card.value.toLocaleString() : card.value }}
</div> </template>
<div style="color: var(--text-secondary); margin-top: 8px">{{ card.label }}</div>
</div> </div>
</el-card> <div style="color: var(--text-secondary); margin-top: 8px; font-size: 13px">{{ card.label }}</div>
</el-col> </div>
</el-row> </el-card>
</div>
<!-- 图表 -->
<el-row :gutter="16" style="margin-bottom: 16px"> <el-row :gutter="16" style="margin-bottom: 16px">
<el-col :span="12"> <el-col :span="12">
<el-card shadow="hover"> <el-card shadow="hover">
@@ -227,3 +246,14 @@ onMounted(() => {
</el-row> </el-row>
</div> </div>
</template> </template>
<style scoped>
.stat-cards {
display: flex;
gap: 16px;
}
.stat-card {
flex: 1;
min-width: 0;
}
</style>
+173 -1
View File
@@ -7,6 +7,7 @@ import {
getSignInStatistics, getSignInStatistics,
getHistoricalStatistics, getHistoricalStatistics,
} from '@/api/modules/datacount' } from '@/api/modules/datacount'
import { getPaymentRecords } from '@/api/modules/payment'
import { download } from '@/api/request' import { download } from '@/api/request'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -27,6 +28,88 @@ const filters = reactive({
periodType: 'DAY', periodType: 'DAY',
}) })
// ===== 支付记录 =====
const paymentLoading = ref(false)
const paymentList = ref<any[]>([])
const paymentTotal = ref(0)
const paymentPage = ref(1)
const paymentPageSize = ref(10)
const paymentFilters = reactive({
memberId: '',
payStatus: '',
tradeType: '',
})
function tradeTypeLabel(type: string) {
const map: Record<string, string> = { ALIPAY: '支付宝', WECHAT: '微信', QRCODE: '扫码支付' }
return map[type] || type || '-'
}
function payStatusLabel(status: string) {
const map: Record<string, string> = { PENDING: '待支付', SUCCESS: '成功', FAIL: '失败', CLOSED: '已关闭' }
return map[status] || status || '-'
}
function payStatusTagType(status: string) {
const map: Record<string, string> = { PENDING: 'warning', SUCCESS: 'success', FAIL: 'danger', CLOSED: 'info' }
return map[status] || 'info'
}
function orderTypeLabel(type: string) {
const map: Record<string, string> = {
MEMBER_CARD: '会员卡',
GROUP_COURSE: '团课',
GOODS: '商品',
STORED_CARD_RECHARGE: '储值卡充值',
}
return map[type] || type || '-'
}
async function fetchPaymentData() {
paymentLoading.value = true
try {
const params: any = { page: paymentPage.value, pageSize: paymentPageSize.value }
if (paymentFilters.memberId) params.memberId = paymentFilters.memberId
if (paymentFilters.payStatus) params.payStatus = paymentFilters.payStatus
if (paymentFilters.tradeType) params.tradeType = paymentFilters.tradeType
const res: any = await getPaymentRecords(params)
if (res?.data) {
paymentList.value = res.data.records || []
paymentTotal.value = res.data.total || 0
}
} catch {
paymentList.value = []
paymentTotal.value = 0
} finally {
paymentLoading.value = false
}
}
function handlePaymentSearch() {
paymentPage.value = 1
fetchPaymentData()
}
function handlePaymentReset() {
paymentFilters.memberId = ''
paymentFilters.payStatus = ''
paymentFilters.tradeType = ''
paymentPage.value = 1
fetchPaymentData()
}
function handlePaymentPageChange(page: number) {
paymentPage.value = page
fetchPaymentData()
}
function handlePaymentSizeChange(size: number) {
paymentPageSize.value = size
paymentPage.value = 1
fetchPaymentData()
}
// ===== 数据报表 =====
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
try { try {
@@ -124,6 +207,7 @@ function dateLabel(d: string | null) {
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchPaymentData()
}) })
</script> </script>
@@ -256,7 +340,7 @@ onMounted(() => {
</el-row> </el-row>
<!-- Historical data table --> <!-- Historical data table -->
<el-card shadow="never"> <el-card shadow="never" style="margin-bottom: 16px">
<template #header><strong>历史统计</strong></template> <template #header><strong>历史统计</strong></template>
<el-table :data="historyList" v-loading="loading" stripe> <el-table :data="historyList" v-loading="loading" stripe>
<el-table-column label="统计日期" width="170" align="center"> <el-table-column label="统计日期" width="170" align="center">
@@ -281,6 +365,88 @@ onMounted(() => {
暂无历史统计数据 暂无历史统计数据
</div> </div>
</el-card> </el-card>
<!-- 支付记录 -->
<el-card shadow="never">
<template #header><strong>支付记录</strong></template>
<el-form :model="paymentFilters" inline style="margin-bottom: 12px">
<el-form-item label="用户编号">
<el-input v-model="paymentFilters.memberId" placeholder="会员ID" clearable style="width: 140px" size="small" />
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="paymentFilters.payStatus" placeholder="全部" clearable style="width: 110px" size="small">
<el-option label="成功" value="SUCCESS" />
<el-option label="待支付" value="PENDING" />
<el-option label="失败" value="FAIL" />
<el-option label="已关闭" value="CLOSED" />
</el-select>
</el-form-item>
<el-form-item label="支付方式">
<el-select v-model="paymentFilters.tradeType" placeholder="全部" clearable style="width: 110px" size="small">
<el-option label="支付宝" value="ALIPAY" />
<el-option label="微信" value="WECHAT" />
<el-option label="扫码支付" value="QRCODE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="handlePaymentSearch">查询</el-button>
<el-button size="small" @click="handlePaymentReset">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="paymentList" v-loading="paymentLoading" stripe size="small">
<el-table-column prop="memberId" label="用户编号" width="90" align="center" />
<el-table-column label="支付方式" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ tradeTypeLabel(row.tradeType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
{{ orderTypeLabel(row.orderType) }}
</template>
</el-table-column>
<el-table-column prop="goodsDesc" label="购买内容" min-width="150" show-overflow-tooltip />
<el-table-column label="金额" width="100" align="center">
<template #default="{ row }">
<span style="color: #1890ff; font-weight: 600">
¥{{ row.transAmt != null ? row.transAmt.toFixed(2) : '0.00' }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(row.payStatus)" size="small">
{{ payStatusLabel(row.payStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付日期" width="160">
<template #default="{ row }">
{{ row.payTime || row.createdAt || '-' }}
</template>
</el-table-column>
<el-table-column prop="orderNo" label="订单号" width="180" show-overflow-tooltip />
</el-table>
<div v-if="paymentList.length === 0 && !paymentLoading" class="empty-tip">
暂无支付记录
</div>
<div v-if="paymentTotal > 0" style="margin-top: 12px; text-align: right">
<el-pagination
v-model:current-page="paymentPage"
v-model:page-size="paymentPageSize"
:total="paymentTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
size="small"
@current-change="handlePaymentPageChange"
@size-change="handlePaymentSizeChange"
/>
</div>
</el-card>
</div> </div>
</template> </template>
@@ -288,4 +454,10 @@ onMounted(() => {
.search-card { .search-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
.empty-tip {
color: #909399;
font-size: 13px;
text-align: center;
padding: 40px;
}
</style> </style>
+98 -23
View File
@@ -9,6 +9,7 @@ import {
restoreGroupCourse, restoreGroupCourse,
uploadImage, uploadImage,
getPresignedUrl, getPresignedUrl,
getGroupCourseQRCode,
} from '@/api/modules/groupCourse' } from '@/api/modules/groupCourse'
import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType' import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
@@ -27,6 +28,7 @@ interface CourseItem {
coverImage: string | null coverImage: string | null
description: string description: string
storedValueAmount: number storedValueAmount: number
isRecurring: boolean | null
createdAt: string | null createdAt: string | null
updatedAt: string | null updatedAt: string | null
deletedAt: string | null deletedAt: string | null
@@ -72,6 +74,12 @@ const saving = ref(false)
const adminPassword = ref('') const adminPassword = ref('')
const pendingCoverFile = ref<File | null>(null) const pendingCoverFile = ref<File | null>(null)
const coverPreviewUrl = ref('') const coverPreviewUrl = ref('')
const recurringEnabled = ref(false)
// ===== 二维码弹窗 =====
const qrVisible = ref(false)
const qrLoading = ref(false)
const qrData = ref<{ base64: string; courseId: number; courseName: string } | null>(null)
const form = reactive({ const form = reactive({
id: 0, id: 0,
@@ -79,9 +87,8 @@ const form = reactive({
coachId: null as number | null, coachId: null as number | null,
courseType: null as number | null, courseType: null as number | null,
startTime: '', startTime: '',
endTime: '', durationMinutes: 60,
maxMembers: 20, maxMembers: 20,
currentMembers: 0,
status: 0, status: 0,
location: '', location: '',
coverImage: '', coverImage: '',
@@ -301,15 +308,15 @@ function openCreate() {
adminPassword.value = '' adminPassword.value = ''
pendingCoverFile.value = null pendingCoverFile.value = null
coverPreviewUrl.value = '' coverPreviewUrl.value = ''
recurringEnabled.value = false
Object.assign(form, { Object.assign(form, {
id: 0, id: 0,
courseName: '', courseName: '',
coachId: null, coachId: null,
courseType: null, courseType: null,
startTime: '', startTime: '',
endTime: '', durationMinutes: 60,
maxMembers: 20, maxMembers: 20,
currentMembers: 0,
status: 0, status: 0,
location: '', location: '',
coverImage: '', coverImage: '',
@@ -329,14 +336,20 @@ function openEdit(row: CourseItem) {
form.coachId = row.coachId ?? null form.coachId = row.coachId ?? null
form.courseType = toNum(row.courseType) form.courseType = toNum(row.courseType)
form.maxMembers = row.maxMembers ?? 20 form.maxMembers = row.maxMembers ?? 20
form.currentMembers = row.currentMembers ?? 0
form.status = toNum(row.status) form.status = toNum(row.status)
form.location = row.location || '' form.location = row.location || ''
form.coverImage = row.coverImage || '' form.coverImage = row.coverImage || ''
form.description = row.description || '' form.description = row.description || ''
form.storedValueAmount = row.storedValueAmount ?? 0 form.storedValueAmount = row.storedValueAmount ?? 0
form.startTime = row.startTime ? formatDate(row.startTime, 'YYYY-MM-DD HH:mm:ss') : '' form.startTime = row.startTime ? formatDate(row.startTime, 'YYYY-MM-DD HH:mm:ss') : ''
form.endTime = row.endTime ? formatDate(row.endTime, 'YYYY-MM-DD HH:mm:ss') : '' // 根据 startTime/endTime 反算课程时长
if (row.startTime && row.endTime) {
const diff = new Date(row.endTime).getTime() - new Date(row.startTime).getTime()
form.durationMinutes = Math.max(1, Math.round(diff / 60000))
} else {
form.durationMinutes = 60
}
recurringEnabled.value = row.isRecurring === true
dialogVisible.value = true dialogVisible.value = true
// 如果有已有封面,获取预签名URL用于预览 // 如果有已有封面,获取预签名URL用于预览
if (form.coverImage) { if (form.coverImage) {
@@ -365,7 +378,7 @@ function handleImgError(e: Event) {
} }
function handleDialogClosed() { function handleDialogClosed() {
Object.assign(form, { startTime: '', endTime: '' }) Object.assign(form, { startTime: '', durationMinutes: 60 })
adminPassword.value = '' adminPassword.value = ''
if (coverPreviewUrl.value) { if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value) URL.revokeObjectURL(coverPreviewUrl.value)
@@ -379,8 +392,15 @@ async function handleSave() {
try { try {
const body: any = { ...form } const body: any = { ...form }
delete body.id delete body.id
if (body.startTime) body.startTime = new Date(body.startTime).toISOString() delete body.durationMinutes
if (body.endTime) body.endTime = new Date(body.endTime).toISOString() body.isRecurring = recurringEnabled.value
if (body.startTime) {
const start = new Date(body.startTime)
body.startTime = start.toISOString()
// 根据上课时间 + 课程时长计算下课时间
const end = new Date(start.getTime() + (form.durationMinutes || 60) * 60000)
body.endTime = end.toISOString()
}
if (isEdit.value) { if (isEdit.value) {
if (!adminPassword.value) { if (!adminPassword.value) {
@@ -461,6 +481,32 @@ async function handleRestore(row: CourseItem) {
} }
} }
// ===== 二维码 =====
async function showQRCode(row: CourseItem) {
qrVisible.value = true
qrLoading.value = true
qrData.value = null
try {
const res: any = await getGroupCourseQRCode(row.id)
if (res) {
qrData.value = res
}
} catch {
ElMessage.error('获取二维码失败')
qrVisible.value = false
} finally {
qrLoading.value = false
}
}
function downloadQRCode() {
if (!qrData.value?.base64) return
const a = document.createElement('a')
a.href = qrData.value.base64
a.download = `签到二维码_${qrData.value.courseName}_${qrData.value.courseId}.png`
a.click()
}
onMounted(() => { onMounted(() => {
fetchTypes() fetchTypes()
loadData() loadData()
@@ -624,15 +670,23 @@ onMounted(() => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="常态化" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.isRecurring" type="success" size="small">是</el-tag>
<span v-else style="color: #c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170"> <el-table-column label="创建时间" width="170">
<template #default="{ row }"> <template #default="{ row }">
{{ row.createdAt ? formatDate(row.createdAt, 'YYYY-MM-DD HH:mm') : '-' }} {{ row.createdAt ? formatDate(row.createdAt, 'YYYY-MM-DD HH:mm') : '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="openEdit(row)" v-permission="'business:groupCourse:edit'">编辑</el-button> <el-button type="primary" link @click="openEdit(row)" v-permission="'business:groupCourse:edit'">编辑</el-button>
<el-button type="warning" link @click="showQRCode(row)">二维码</el-button>
<el-button <el-button
v-if="isDeleted(row)" v-if="isDeleted(row)"
type="success" type="success"
@@ -691,14 +745,9 @@ onMounted(() => {
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="下课时间"> <el-form-item label="课程时长">
<el-date-picker <el-input-number v-model="form.durationMinutes" :min="1" :max="480" style="width: 160px" />
v-model="form.endTime" <span style="margin-left: 8px; color: var(--text-secondary)">分钟</span>
type="datetime"
placeholder="选择下课时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item> </el-form-item>
<el-form-item label="上课地点"> <el-form-item label="上课地点">
<el-input v-model="form.location" placeholder="请输入上课地点" maxlength="100" /> <el-input v-model="form.location" placeholder="请输入上课地点" maxlength="100" />
@@ -709,11 +758,6 @@ onMounted(() => {
<el-input-number v-model="form.maxMembers" :min="1" style="width: 100%" /> <el-input-number v-model="form.maxMembers" :min="1" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="已报名人数">
<el-input-number v-model="form.currentMembers" :min="0" :max="form.maxMembers" style="width: 100%" />
</el-form-item>
</el-col>
</el-row> </el-row>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%"> <el-select v-model="form.status" style="width: 100%">
@@ -721,6 +765,9 @@ onMounted(() => {
<el-option label="已取消" :value="1" /> <el-option label="已取消" :value="1" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="常态化团课">
<el-switch v-model="recurringEnabled" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="课程描述"> <el-form-item label="课程描述">
<el-input <el-input
v-model="form.description" v-model="form.description"
@@ -769,5 +816,33 @@ onMounted(() => {
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button> <el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- QR Code Dialog -->
<el-dialog
v-model="qrVisible"
title="签到二维码"
width="400px"
:close-on-click-modal="false"
>
<div v-loading="qrLoading" style="text-align: center; padding: 16px">
<template v-if="qrData">
<img
:src="qrData.base64"
alt="签到二维码"
style="width: 260px; height: 260px; border: 1px solid #dcdfe6; border-radius: 8px"
/>
<div style="margin-top: 12px; color: var(--text-secondary); font-size: 13px">
{{ qrData.courseName }}
</div>
</template>
<div v-else-if="!qrLoading" style="color: #909399; padding: 40px 0">
暂无数据
</div>
</div>
<template #footer>
<el-button @click="qrVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!qrData" @click="downloadQRCode">下载保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -7,6 +7,7 @@ import {
updateMemberCard, updateMemberCard,
deleteMemberCard, deleteMemberCard,
} from '@/api/modules/memberCard' } from '@/api/modules/memberCard'
import { getConfigByKey, updateConfig } from '@/api/modules/config'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
interface CardItem { interface CardItem {
@@ -24,6 +25,49 @@ interface CardItem {
updatedAt: string | null updatedAt: string | null
} }
// ---- 团课免费取消次数配置 ----
const cancelFreeLimit = ref<number>(3)
const cancelFreeLimitConfigId = ref<number | null>(null)
const cancelFreeLimitLoading = ref(false)
const cancelFreeLimitSaving = ref(false)
async function fetchCancelFreeLimit() {
cancelFreeLimitLoading.value = true
try {
const res: any = await getConfigByKey('group_course.cancel_free_limit')
if (res) {
cancelFreeLimitConfigId.value = res.id || res.configId || null
cancelFreeLimit.value = parseInt(res.configValue || res.value) || 3
}
} catch {
// ignore, use default
} finally {
cancelFreeLimitLoading.value = false
}
}
async function saveCancelFreeLimit() {
if (!cancelFreeLimitConfigId.value) {
ElMessage.error('配置记录不存在,无法保存')
return
}
cancelFreeLimitSaving.value = true
try {
await updateConfig(cancelFreeLimitConfigId.value, {
configName: '团课取消预约免手续费次数',
configKey: 'group_course.cancel_free_limit',
configValue: String(cancelFreeLimit.value),
configType: 'Y',
})
ElMessage.success(`免费取消次数已更新为 ${cancelFreeLimit.value} 次。所有会员将立即应用新规则,后台实时生效。`)
await fetchCancelFreeLimit()
} catch {
ElMessage.error('保存失败')
} finally {
cancelFreeLimitSaving.value = false
}
}
// ---- State ---- // ---- State ----
const loading = ref(false) const loading = ref(false)
const list = ref<CardItem[]>([]) const list = ref<CardItem[]>([])
@@ -217,6 +261,7 @@ async function doDelete(row: CardItem) {
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchCancelFreeLimit()
}) })
</script> </script>
@@ -352,6 +397,38 @@ onMounted(() => {
</div> </div>
</el-card> </el-card>
<!-- 团课免费取消次数配置 -->
<el-card v-loading="cancelFreeLimitLoading" style="margin-top: 16px">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<el-icon :size="18"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg></el-icon>
<span style="font-weight: 600">团课取消预约免手续费次数</span>
<el-tag size="small" type="warning">全局配置</el-tag>
</div>
</template>
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap">
<span style="color: var(--text-secondary); font-size: 14px">会员前</span>
<el-input-number
v-model="cancelFreeLimit"
:min="0"
:max="99"
controls-position="right"
style="width: 120px"
/>
<span style="color: var(--text-secondary); font-size: 14px">次取消预约免收手续费,超出后扣除 10% 手续费</span>
<el-button
type="primary"
:loading="cancelFreeLimitSaving"
@click="saveCancelFreeLimit()"
>
保存
</el-button>
</div>
<div style="margin-top: 8px; color: var(--text-muted, #909399); font-size: 12px">
当前生效值:{{ cancelFreeLimit }} 次。修改后实时生效,对所有会员立即应用新规则。
</div>
</el-card>
<!-- Create Dialog --> <!-- Create Dialog -->
<el-dialog v-model="createVisible" title="新增会员卡" width="520px"> <el-dialog v-model="createVisible" title="新增会员卡" width="520px">
<el-form :model="createForm" label-width="110px"> <el-form :model="createForm" label-width="110px">
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getRevenueStatistics } from '@/api/modules/payment'
interface RevenueData {
todayIncome: number
todayRefund: number
todayOrderCount: number
monthIncome: number
monthRefund: number
monthOrderCount: number
yearIncome: number
yearRefund: number
yearOrderCount: number
}
const loading = ref(false)
const revenue = ref<RevenueData>({
todayIncome: 0, todayRefund: 0, todayOrderCount: 0,
monthIncome: 0, monthRefund: 0, monthOrderCount: 0,
yearIncome: 0, yearRefund: 0, yearOrderCount: 0,
})
async function fetchData() {
loading.value = true
try {
const res: any = await getRevenueStatistics()
if (res?.data) {
revenue.value = res.data
}
} catch {
// fallback
} finally {
loading.value = false
}
}
function fmt(v: number) {
return v != null ? v.toFixed(2) : '0.00'
}
onMounted(() => { fetchData() })
</script>
<template>
<div v-loading="loading">
<h2 style="margin-bottom: 16px">营收详情</h2>
<!-- 今日 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">今日</span>
<el-tag size="small" type="primary">本日</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.todayIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.todayRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.todayOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.todayIncome - revenue.todayRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 当月 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">当月</span>
<el-tag size="small" type="success">本月</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.monthIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.monthRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.monthOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.monthIncome - revenue.monthRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 当年 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="section-header">
<span class="section-title">当年</span>
<el-tag size="small" type="warning">本年</el-tag>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value income">¥{{ fmt(revenue.yearIncome) }}</div>
<div class="mini-label">收入</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value refund">¥{{ fmt(revenue.yearRefund) }}</div>
<div class="mini-label">退款</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #303133">{{ revenue.yearOrderCount }}</div>
<div class="mini-label">订单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value" style="color: #52c41a">¥{{ fmt(revenue.yearIncome - revenue.yearRefund) }}</div>
<div class="mini-label">净收入</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<style scoped>
.section-card {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 16px;
font-weight: 600;
}
.mini-stat {
text-align: center;
padding: 16px 8px;
border-radius: 8px;
background: #fafafa;
}
.mini-value {
font-size: 22px;
font-weight: 700;
}
.mini-value.income {
color: #1890ff;
}
.mini-value.refund {
color: #ff4d4f;
}
.mini-label {
color: var(--text-secondary, #909399);
font-size: 13px;
margin-top: 4px;
}
</style>