更新多个功能

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")
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);
/**
@@ -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));
// 5. 价格排序 / 6. 剩余名额最多排序
@@ -145,6 +154,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
spec = spec.bind("limit", size);
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.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
return entity;
}).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));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
@@ -227,6 +244,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) {
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();
}
@@ -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")
private String qrCodePath;
//是否常态化团课
@Schema(description = "是否常态化团课", example = "true")
private Boolean isRecurring;
public String getCourseName() {
return courseName;
}
@@ -163,4 +167,12 @@ public class GroupCourse extends BaseDomain{
public void setQrCodePath(String 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")
private Integer size = 10;
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
private Boolean isRecurring;
// ===== Getters and Setters =====
public String getCourseName() {
@@ -113,4 +116,12 @@ public class GroupCourseQueryDto {
public void setSize(Integer 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")
private String qrCodePath;
//是否常态化团课
@Column("is_recurring")
private Boolean isRecurring;
public String getCourseName() {
return courseName;
}
@@ -165,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity {
public void setQrCodePath(String 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);
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.util.AuthUtil;
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.tags.Tag;
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.ServerResponse;
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.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.setStatus(0L);
entity.setCurrentMembers(0);
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain);
@@ -147,6 +148,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<GroupCourse> update(GroupCourse groupCourse) {
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
entity.setUpdatedAt(LocalDateTime.now());
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return r2dbcEntityTemplate.update(entity)
.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 处理的记录数
*/
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -46,6 +47,7 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler;
private final DatabaseClient databaseClient;
// 预约提前时间限制(分钟)
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -56,12 +58,14 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher,
BookingSagaHandler bookingSagaHandler) {
BookingSagaHandler bookingSagaHandler,
DatabaseClient databaseClient) {
this.bookingRepository = bookingRepository;
this.courseRepository = courseRepository;
this.redisService = redisService;
this.bookingReminderEventPublisher = bookingReminderEventPublisher;
this.bookingSagaHandler = bookingSagaHandler;
this.databaseClient = databaseClient;
}
@Override
@@ -347,6 +351,78 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
.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
public Mono<Integer> processAbsentMembers() {
logger.info("开始处理已开始课程但未到场会员的预约记录");
@@ -347,6 +347,9 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath());
}
if (groupCourse.getIsRecurring() != null) {
existing.setIsRecurring(groupCourse.getIsRecurring());
}
return groupCourseRepository.update(existing);
})
.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.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Repository
@@ -38,4 +39,24 @@ public interface PaymentOrderRepository extends R2dbcRepository<PaymentOrder, Lo
Flux<PaymentOrder> findAllByDeletedAtIsNull();
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;
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.RevenueStatistics;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -30,4 +32,10 @@ public interface PaymentService {
Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType);
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.payment.config.HuifuProperties;
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.RevenueStatistics;
import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository;
import cn.novalon.gym.manage.payment.service.PaymentNotifyService;
@@ -934,4 +936,76 @@ public class PaymentServiceImpl implements PaymentService {
})
.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.groupcourse.handler.GroupCourseBookingHandler;
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.GroupCourseTypeHandler;
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.SysUserMessageHandler;
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.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -80,10 +82,12 @@ public class SystemRouter {
GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler,
BannerHandler bannerHandler,
CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler,
PaymentRevenueHandler paymentRevenueHandler,
CommonUploadHandler commonUploadHandler) {
return route()
@@ -348,7 +352,19 @@ public class SystemRouter {
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.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}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
@@ -404,6 +420,10 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ===== 支付营业数据 =====
.GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics)
.GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords)
.build();
}
}
@@ -15,7 +15,7 @@ spring:
url: jdbc:postgresql://localhost:55432/manage_system
user: novalon
password: novalon123
enabled: false
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
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) {
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'),
meta: { title: '推荐团课', icon: 'Star' },
},
{
path: 'banner',
name: 'Banner',
component: () => import('@/views/banner/index.vue'),
meta: { title: '轮播图', icon: 'Picture' },
},
{
path: 'datacount',
name: 'DataCount',
component: () => import('@/views/datacount/index.vue'),
meta: { title: '数据报表', icon: 'DataAnalysis' },
},
{
path: 'payment/revenue',
name: 'PaymentRevenue',
component: () => import('@/views/payment/revenue/index.vue'),
meta: { title: '营收详情', icon: 'Money' },
},
{
path: '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: '/banner',
name: 'Banner',
meta: { title: '轮播图', icon: 'Picture' },
},
{
path: '/checkIn',
name: 'CheckIn',
@@ -49,6 +54,11 @@ const allMenus: MenuItem[] = [
name: 'DataCount',
meta: { title: '数据报表', icon: 'DataAnalysis', permission: 'business:dataCount:view' },
},
{
path: '/payment/revenue',
name: 'PaymentRevenue',
meta: { title: '营收详情', icon: 'Money', permission: 'business:payment:view' },
},
{
path: '/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,
getSignInStatistics,
} from '@/api/modules/datacount'
import { getRevenueStatistics } from '@/api/modules/payment'
import dayjs from 'dayjs'
interface StatCard {
label: string
value: number | string
color: string
suffix?: string
}
const loading = ref(false)
@@ -20,6 +22,7 @@ const statCards = ref<StatCard[]>([
{ label: '今日新增', value: 0, color: '#52c41a' },
{ label: '今日签到', value: 0, color: '#722ed1' },
{ label: '今日预约', value: 0, color: '#faad14' },
{ label: '今日营业额', value: 0, color: '#e6a23c', suffix: '元' },
])
const memberChartRef = ref()
@@ -92,7 +95,7 @@ async function fetchData() {
}).catch(() => {}),
getBookingStatistics(range).then((res: any) => {
bookings[i] = res?.newBookings ?? 0
bookingData[i] = res?.newBookings ?? 0
cancelData[i] = res?.cancelBookings ?? 0
if (i === todayIdx) {
statCards.value[3].value = res?.newBookings ?? 0
@@ -107,6 +110,17 @@ async function fetchData() {
}).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)
} catch {
// fallback: empty data
@@ -192,19 +206,24 @@ onMounted(() => {
<div v-loading="loading">
<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">
<el-card shadow="hover">
<div style="text-align: center">
<div style="font-size: 28px; font-weight: bold" :style="{ color: card.color }">
<!-- 核心指标卡片 -->
<div class="stat-cards" style="margin-bottom: 16px">
<el-card v-for="card in statCards" :key="card.label" shadow="hover" class="stat-card">
<div style="text-align: center">
<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 }}
</div>
<div style="color: var(--text-secondary); margin-top: 8px">{{ card.label }}</div>
</template>
</div>
</el-card>
</el-col>
</el-row>
<div style="color: var(--text-secondary); margin-top: 8px; font-size: 13px">{{ card.label }}</div>
</div>
</el-card>
</div>
<!-- 图表 -->
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :span="12">
<el-card shadow="hover">
@@ -227,3 +246,14 @@ onMounted(() => {
</el-row>
</div>
</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,
getHistoricalStatistics,
} from '@/api/modules/datacount'
import { getPaymentRecords } from '@/api/modules/payment'
import { download } from '@/api/request'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
@@ -27,6 +28,88 @@ const filters = reactive({
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() {
loading.value = true
try {
@@ -124,6 +207,7 @@ function dateLabel(d: string | null) {
onMounted(() => {
fetchData()
fetchPaymentData()
})
</script>
@@ -256,7 +340,7 @@ onMounted(() => {
</el-row>
<!-- Historical data table -->
<el-card shadow="never">
<el-card shadow="never" style="margin-bottom: 16px">
<template #header><strong>历史统计</strong></template>
<el-table :data="historyList" v-loading="loading" stripe>
<el-table-column label="统计日期" width="170" align="center">
@@ -281,6 +365,88 @@ onMounted(() => {
暂无历史统计数据
</div>
</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>
</template>
@@ -288,4 +454,10 @@ onMounted(() => {
.search-card {
margin-bottom: 16px;
}
.empty-tip {
color: #909399;
font-size: 13px;
text-align: center;
padding: 40px;
}
</style>
+98 -23
View File
@@ -9,6 +9,7 @@ import {
restoreGroupCourse,
uploadImage,
getPresignedUrl,
getGroupCourseQRCode,
} from '@/api/modules/groupCourse'
import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType'
import { formatDate } from '@/utils/format'
@@ -27,6 +28,7 @@ interface CourseItem {
coverImage: string | null
description: string
storedValueAmount: number
isRecurring: boolean | null
createdAt: string | null
updatedAt: string | null
deletedAt: string | null
@@ -72,6 +74,12 @@ const saving = ref(false)
const adminPassword = ref('')
const pendingCoverFile = ref<File | null>(null)
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({
id: 0,
@@ -79,9 +87,8 @@ const form = reactive({
coachId: null as number | null,
courseType: null as number | null,
startTime: '',
endTime: '',
durationMinutes: 60,
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
@@ -301,15 +308,15 @@ function openCreate() {
adminPassword.value = ''
pendingCoverFile.value = null
coverPreviewUrl.value = ''
recurringEnabled.value = false
Object.assign(form, {
id: 0,
courseName: '',
coachId: null,
courseType: null,
startTime: '',
endTime: '',
durationMinutes: 60,
maxMembers: 20,
currentMembers: 0,
status: 0,
location: '',
coverImage: '',
@@ -329,14 +336,20 @@ function openEdit(row: CourseItem) {
form.coachId = row.coachId ?? null
form.courseType = toNum(row.courseType)
form.maxMembers = row.maxMembers ?? 20
form.currentMembers = row.currentMembers ?? 0
form.status = toNum(row.status)
form.location = row.location || ''
form.coverImage = row.coverImage || ''
form.description = row.description || ''
form.storedValueAmount = row.storedValueAmount ?? 0
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
// 如果有已有封面,获取预签名URL用于预览
if (form.coverImage) {
@@ -365,7 +378,7 @@ function handleImgError(e: Event) {
}
function handleDialogClosed() {
Object.assign(form, { startTime: '', endTime: '' })
Object.assign(form, { startTime: '', durationMinutes: 60 })
adminPassword.value = ''
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value)
@@ -379,8 +392,15 @@ async function handleSave() {
try {
const body: any = { ...form }
delete body.id
if (body.startTime) body.startTime = new Date(body.startTime).toISOString()
if (body.endTime) body.endTime = new Date(body.endTime).toISOString()
delete body.durationMinutes
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 (!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(() => {
fetchTypes()
loadData()
@@ -624,15 +670,23 @@ onMounted(() => {
</template>
</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">
<template #default="{ row }">
{{ row.createdAt ? formatDate(row.createdAt, 'YYYY-MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<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
v-if="isDeleted(row)"
type="success"
@@ -691,14 +745,9 @@ onMounted(() => {
style="width: 100%"
/>
</el-form-item>
<el-form-item label="下课时间">
<el-date-picker
v-model="form.endTime"
type="datetime"
placeholder="选择下课时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
<el-form-item label="课程时长">
<el-input-number v-model="form.durationMinutes" :min="1" :max="480" style="width: 160px" />
<span style="margin-left: 8px; color: var(--text-secondary)">分钟</span>
</el-form-item>
<el-form-item label="上课地点">
<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-form-item>
</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-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
@@ -721,6 +765,9 @@ onMounted(() => {
<el-option label="已取消" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="常态化团课">
<el-switch v-model="recurringEnabled" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="课程描述">
<el-input
v-model="form.description"
@@ -769,5 +816,33 @@ onMounted(() => {
<el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
</template>
</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>
</template>
@@ -7,6 +7,7 @@ import {
updateMemberCard,
deleteMemberCard,
} from '@/api/modules/memberCard'
import { getConfigByKey, updateConfig } from '@/api/modules/config'
import { formatDate } from '@/utils/format'
interface CardItem {
@@ -24,6 +25,49 @@ interface CardItem {
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 ----
const loading = ref(false)
const list = ref<CardItem[]>([])
@@ -217,6 +261,7 @@ async function doDelete(row: CardItem) {
onMounted(() => {
fetchData()
fetchCancelFreeLimit()
})
</script>
@@ -352,6 +397,38 @@ onMounted(() => {
</div>
</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 -->
<el-dialog v-model="createVisible" title="新增会员卡" width="520px">
<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>