2 Commits

104 changed files with 12391 additions and 236 deletions
@@ -0,0 +1,175 @@
package cn.novalon.gym.manage.auth.handler;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PhoneAuthHandlerTest {
@Mock
private PhoneAuthService phoneAuthService;
private PhoneAuthHandler phoneAuthHandler;
@BeforeEach
void setUp() {
phoneAuthHandler = new PhoneAuthHandler(phoneAuthService);
}
// ==================== oneClickLogin ====================
@Test
void oneClickLogin_shouldReturnOkWithLoginResult() {
PhoneLoginVO loginVO = new PhoneLoginVO();
loginVO.setAccessToken("test-jwt-token");
loginVO.setPhone("13800138000");
PhoneLoginDto dto = new PhoneLoginDto();
dto.setAccessToken("dcloud-access-token");
when(phoneAuthService.oneClickLogin(any(PhoneLoginDto.class))).thenReturn(Mono.just(loginVO));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(dto));
Mono<ServerResponse> result = phoneAuthHandler.oneClickLogin(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(phoneAuthService).oneClickLogin(any(PhoneLoginDto.class));
}
@Test
void oneClickLogin_shouldPropagateServiceError() {
PhoneLoginDto dto = new PhoneLoginDto();
dto.setAccessToken("invalid-token");
when(phoneAuthService.oneClickLogin(any(PhoneLoginDto.class)))
.thenReturn(Mono.error(new RuntimeException("Auth failed")));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(dto));
Mono<ServerResponse> result = phoneAuthHandler.oneClickLogin(request);
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
// ==================== sendSmsCode ====================
@Test
void sendSmsCode_shouldReturnOkWithSuccessTrue() {
SendCodeRequest sendCodeRequest = new SendCodeRequest();
sendCodeRequest.setPhone("13800138000");
when(phoneAuthService.sendSmsCode("13800138000")).thenReturn(Mono.just(true));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(sendCodeRequest));
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void sendSmsCode_shouldReturnOkWithSuccessFalseWhenServiceReturnsFalse() {
SendCodeRequest sendCodeRequest = new SendCodeRequest();
sendCodeRequest.setPhone("13800138000");
when(phoneAuthService.sendSmsCode("13800138000")).thenReturn(Mono.just(false));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(sendCodeRequest));
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void sendSmsCode_shouldPropagateError() {
SendCodeRequest sendCodeRequest = new SendCodeRequest();
sendCodeRequest.setPhone("13800138000");
when(phoneAuthService.sendSmsCode("13800138000"))
.thenReturn(Mono.error(new RuntimeException("SMS service unavailable")));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(sendCodeRequest));
Mono<ServerResponse> result = phoneAuthHandler.sendSmsCode(request);
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
// ==================== codeLogin ====================
@Test
void codeLogin_shouldReturnOkWithLoginResult() {
PhoneLoginVO loginVO = new PhoneLoginVO();
loginVO.setAccessToken("test-jwt-token");
PhoneCodeLoginDto dto = new PhoneCodeLoginDto();
dto.setPhone("13800138000");
dto.setCode("123456");
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class))).thenReturn(Mono.just(loginVO));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(dto));
Mono<ServerResponse> result = phoneAuthHandler.codeLogin(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void codeLogin_shouldPropagateServiceError() {
PhoneCodeLoginDto dto = new PhoneCodeLoginDto();
dto.setPhone("13800138000");
dto.setCode("wrong-code");
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class)))
.thenReturn(Mono.error(new RuntimeException("Invalid code")));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(dto));
Mono<ServerResponse> result = phoneAuthHandler.codeLogin(request);
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
}
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-brand</artifactId>
<packaging>jar</packaging>
<name>Gym Brand</name>
<description>Brand Customization Module - Logo Upload, Color Settings, Real-time Preview</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Aliyun OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>default-jar</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,39 @@
package cn.novalon.gym.manage.brand.config;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
/**
* 品牌预览 WebSocket 配置
*
* @author 张翔
* @date 2026-07-23
*/
@Configuration
public class BrandWebSocketConfig {
@Bean
public HandlerMapping brandWebSocketHandlerMapping(BrandWebSocketHandler brandWebSocketHandler) {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/ws/brand", brandWebSocketHandler);
SimpleUrlHandlerMapping handlerMapping = new SimpleUrlHandlerMapping();
handlerMapping.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
handlerMapping.setUrlMap(map);
return handlerMapping;
}
@Bean
public WebSocketHandlerAdapter brandWebSocketHandlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -0,0 +1,105 @@
package cn.novalon.gym.manage.brand.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云 OSS 配置属性
* <p>
* 配置前缀: brand.oss
*
* @author 张翔
* @date 2026-07-23
*/
@Component
@ConfigurationProperties(prefix = "brand.oss")
public class OssProperties {
/** 是否启用 OSS(默认关闭,仅使用本地存储) */
private boolean enabled = false;
/** OSS Endpoint(如 oss-cn-hangzhou.aliyuncs.com */
private String endpoint;
/** AccessKey ID */
private String accessKeyId;
/** AccessKey Secret */
private String accessKeySecret;
/** Bucket 名称 */
private String bucketName;
/** 自定义域名/CDN域名(可选,用于生成访问URL) */
private String customDomain;
/** 文件存储基础路径(默认 brand) */
private String basePath = "brand";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getAccessKeySecret() {
return accessKeySecret;
}
public void setAccessKeySecret(String accessKeySecret) {
this.accessKeySecret = accessKeySecret;
}
public String getBucketName() {
return bucketName;
}
public void setBucketName(String bucketName) {
this.bucketName = bucketName;
}
public String getCustomDomain() {
return customDomain;
}
public void setCustomDomain(String customDomain) {
this.customDomain = customDomain;
}
public String getBasePath() {
return basePath;
}
public void setBasePath(String basePath) {
this.basePath = basePath;
}
/**
* 判断 OSS 配置是否完整可用
*/
public boolean isConfigured() {
return enabled
&& endpoint != null && !endpoint.isBlank()
&& accessKeyId != null && !accessKeyId.isBlank()
&& accessKeySecret != null && !accessKeySecret.isBlank()
&& bucketName != null && !bucketName.isBlank();
}
}
@@ -0,0 +1,56 @@
package cn.novalon.gym.manage.brand.core.domain;
import java.time.LocalDateTime;
/**
* 品牌配置领域对象
*
* @author 张翔
* @date 2026-07-23
*/
public class BrandConfig {
private Long id;
private String tenantId;
private String logoUrl;
private String backgroundImageUrl;
private String primaryColor;
private String primaryColorRgb;
private String secondaryColor;
private String secondaryColorRgb;
private String fontFamily;
private String brandName;
private String slogan;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public String getBackgroundImageUrl() { return backgroundImageUrl; }
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
public String getPrimaryColor() { return primaryColor; }
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
public String getPrimaryColorRgb() { return primaryColorRgb; }
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
public String getSecondaryColor() { return secondaryColor; }
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
public String getSecondaryColorRgb() { return secondaryColorRgb; }
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
public String getFontFamily() { return fontFamily; }
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
public String getBrandName() { return brandName; }
public void setBrandName(String brandName) { this.brandName = brandName; }
public String getSlogan() { return slogan; }
public void setSlogan(String slogan) { this.slogan = slogan; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}
@@ -0,0 +1,17 @@
package cn.novalon.gym.manage.brand.core.repository;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import reactor.core.publisher.Mono;
/**
* 品牌配置仓储接口
*
* @author 张翔
* @date 2026-07-23
*/
public interface IBrandConfigRepository {
Mono<BrandConfig> findByTenantId(String tenantId);
Mono<BrandConfig> save(BrandConfig brandConfig);
}
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.brand.core.service;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
/**
* 文件存储服务接口(OSS + 本地兜底)
*
* @author 张翔
* @date 2026-07-23
*/
public interface FileStorageService {
/**
* 上传图片文件,返回访问URL
*
* @param filePart 文件数据
* @param directory 存储目录(如 "logo", "background"
* @return 文件访问URL
*/
Mono<String> uploadImage(FilePart filePart, String directory);
/**
* 根据URL删除文件
*/
Mono<Void> deleteFile(String fileUrl);
}
@@ -0,0 +1,49 @@
package cn.novalon.gym.manage.brand.core.service;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
/**
* 品牌配置服务接口
*
* @author 张翔
* @date 2026-07-23
*/
public interface IBrandConfigService {
/**
* 根据租户ID获取品牌配置
*/
Mono<BrandConfig> getBrandConfig(String tenantId);
/**
* 上传Logo
*/
Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart);
/**
* 上传背景图
*/
Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart);
/**
* 更新品牌配色
*/
Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig config);
/**
* 删除Logo(恢复默认)
*/
Mono<BrandConfig> removeLogo(String tenantId);
/**
* 删除背景图(恢复默认)
*/
Mono<BrandConfig> removeBackgroundImage(String tenantId);
/**
* 更新品牌信息(名称、口号)
*/
Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig config);
}
@@ -0,0 +1,172 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 品牌配置服务实现
*
* @author 张翔
* @date 2026-07-23
*/
@Service
public class BrandConfigServiceImpl implements IBrandConfigService {
private final IBrandConfigRepository brandConfigRepository;
private final FileStorageService fileStorageService;
private final BrandWebSocketHandler brandWebSocketHandler;
public BrandConfigServiceImpl(
IBrandConfigRepository brandConfigRepository,
FileStorageService fileStorageService,
BrandWebSocketHandler brandWebSocketHandler) {
this.brandConfigRepository = brandConfigRepository;
this.fileStorageService = fileStorageService;
this.brandWebSocketHandler = brandWebSocketHandler;
}
@Override
public Mono<BrandConfig> getBrandConfig(String tenantId) {
return brandConfigRepository.findByTenantId(tenantId)
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
}
@Override
public Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart) {
return fileStorageService.uploadImage(filePart, "logo")
.flatMap(logoUrl -> getOrCreateConfig(tenantId)
.flatMap(config -> {
// 删除旧Logo
return fileStorageService.deleteFile(config.getLogoUrl())
.then(Mono.defer(() -> {
config.setLogoUrl(logoUrl);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}));
}))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart) {
return fileStorageService.uploadImage(filePart, "background")
.flatMap(bgUrl -> getOrCreateConfig(tenantId)
.flatMap(config -> {
return fileStorageService.deleteFile(config.getBackgroundImageUrl())
.then(Mono.defer(() -> {
config.setBackgroundImageUrl(bgUrl);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}));
}))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig updatedConfig) {
return getOrCreateConfig(tenantId)
.flatMap(config -> {
if (updatedConfig.getPrimaryColor() != null) {
config.setPrimaryColor(updatedConfig.getPrimaryColor());
}
if (updatedConfig.getPrimaryColorRgb() != null) {
config.setPrimaryColorRgb(updatedConfig.getPrimaryColorRgb());
}
if (updatedConfig.getSecondaryColor() != null) {
config.setSecondaryColor(updatedConfig.getSecondaryColor());
}
if (updatedConfig.getSecondaryColorRgb() != null) {
config.setSecondaryColorRgb(updatedConfig.getSecondaryColorRgb());
}
if (updatedConfig.getFontFamily() != null) {
config.setFontFamily(updatedConfig.getFontFamily());
}
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> removeLogo(String tenantId) {
return getOrCreateConfig(tenantId)
.flatMap(config -> fileStorageService.deleteFile(config.getLogoUrl())
.then(Mono.defer(() -> {
config.setLogoUrl(null);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> removeBackgroundImage(String tenantId) {
return getOrCreateConfig(tenantId)
.flatMap(config -> fileStorageService.deleteFile(config.getBackgroundImageUrl())
.then(Mono.defer(() -> {
config.setBackgroundImageUrl(null);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig updatedConfig) {
return getOrCreateConfig(tenantId)
.flatMap(config -> {
if (updatedConfig.getBrandName() != null) {
config.setBrandName(updatedConfig.getBrandName());
}
if (updatedConfig.getSlogan() != null) {
config.setSlogan(updatedConfig.getSlogan());
}
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
/**
* 获取或创建品牌配置
*/
private Mono<BrandConfig> getOrCreateConfig(String tenantId) {
return brandConfigRepository.findByTenantId(tenantId)
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
}
/**
* 为租户创建默认品牌配置
*/
private Mono<BrandConfig> createDefaultConfig(String tenantId) {
BrandConfig config = new BrandConfig();
config.setTenantId(tenantId);
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}
/**
* 通过WebSocket通知前端预览更新
*/
private void notifyPreviewUpdate(BrandConfig config) {
try {
brandWebSocketHandler.broadcastBrandUpdate(config);
} catch (Exception e) {
System.err.println("Failed to broadcast brand update: " + e.getMessage());
}
}
}
@@ -0,0 +1,71 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* 双轨文件存储服务(OSS 优先 + 本地兜底)
* <p>
* 作为 FileStorageService 的 @Primary 实现,编排 OSS 和本地存储:
* <ul>
* <li>上传:优先 OSS,失败则回退到本地存储</li>
* <li>删除:同时删除 OSS 和本地副本(尽力而为)</li>
* </ul>
* 当 OSS 未启用或未配置时,直接使用本地存储。
*
* @author 张翔
* @date 2026-07-23
*/
@Service
@Primary
public class DualFileStorageService implements FileStorageService {
private final FileStorageService ossStorage;
private final FileStorageService localStorage;
private final OssProperties ossProperties;
public DualFileStorageService(
@Qualifier("ossFileStorage") FileStorageService ossStorage,
@Qualifier("localFileStorage") FileStorageService localStorage,
OssProperties ossProperties) {
this.ossStorage = ossStorage;
this.localStorage = localStorage;
this.ossProperties = ossProperties;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
if (ossProperties.isConfigured()) {
return ossStorage.uploadImage(filePart, directory)
.onErrorResume(e -> {
System.err.println("OSS upload failed, falling back to local storage: " + e.getMessage());
return localStorage.uploadImage(filePart, directory);
});
}
return localStorage.uploadImage(filePart, directory);
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
// 本地文件总是尝试删除
Mono<Void> localDelete = localStorage.deleteFile(fileUrl);
if (ossProperties.isConfigured()) {
// OSS 删除:忽略失败(尽力而为)
return ossStorage.deleteFile(fileUrl)
.onErrorResume(e -> Mono.empty())
.then(localDelete);
}
return localDelete;
}
}
@@ -0,0 +1,111 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.UUID;
/**
* 本地文件存储服务(兜底实现)
* <p>
* 当 OSS 不可用时,文件存储到服务器本地磁盘。
* 文件访问通过 /api/files/preview/ 路径提供。
*
* @author 张翔
* @date 2026-07-23
*/
@Service("localFileStorage")
public class LocalFileStorageService implements FileStorageService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
private final String uploadDir;
private final String baseUrl;
public LocalFileStorageService(
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir,
@Value("${brand.file.base-url:http://localhost:8084/api/files}") String baseUrl) {
this.uploadDir = uploadDir;
this.baseUrl = baseUrl;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
String originalFilename = filePart.filename();
String fileExtension = getFileExtension(originalFilename);
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
return Mono.error(new IllegalArgumentException(
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
}
String newFileName = directory + "_" + UUID.randomUUID().toString() + fileExtension;
Path targetDir = Paths.get(uploadDir, "brand", directory);
return Mono.fromCallable(() -> {
if (!Files.exists(targetDir)) {
Files.createDirectories(targetDir);
}
return targetDir;
}).flatMap(dir -> {
Path filePath = dir.resolve(newFileName);
return filePart.transferTo(filePath.toFile()).thenReturn(filePath);
}).flatMap(filePath -> {
try {
long fileSize = Files.size(filePath);
if (fileSize > MAX_FILE_SIZE) {
Files.deleteIfExists(filePath);
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
}
String relativePath = "brand/" + directory + "/" + newFileName;
return Mono.just(baseUrl + "/preview/" + relativePath);
} catch (IOException e) {
return Mono.error(e);
}
});
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
return Mono.fromRunnable(() -> {
try {
String relativePath = extractRelativePath(fileUrl);
if (relativePath != null) {
Path filePath = Paths.get(uploadDir, relativePath);
Files.deleteIfExists(filePath);
}
} catch (IOException e) {
System.err.println("Failed to delete local file: " + fileUrl + ", error: " + e.getMessage());
}
});
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".png";
}
return filename.substring(filename.lastIndexOf("."));
}
private String extractRelativePath(String fileUrl) {
if (fileUrl.contains("/files/preview/")) {
return fileUrl.substring(fileUrl.indexOf("/files/preview/") + "/files/preview/".length());
}
if (fileUrl.contains("/files/")) {
return fileUrl.substring(fileUrl.indexOf("/files/") + "/files/".length());
}
return null;
}
}
@@ -0,0 +1,161 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
/**
* 阿里云 OSS 文件存储服务
* <p>
* 当 brand.oss.enabled=true 且配置完整时启用,
* 将品牌图片上传至阿里云 OSS。
*
* @author 张翔
* @date 2026-07-23
*/
@Service("ossFileStorage")
public class OssFileStorageService implements FileStorageService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
private final OssProperties ossProperties;
private OSS ossClient;
public OssFileStorageService(OssProperties ossProperties) {
this.ossProperties = ossProperties;
}
/**
* 懒加载 OSS 客户端,避免未配置时启动失败
*/
private OSS getOssClient() {
if (ossClient == null && ossProperties.isConfigured()) {
synchronized (this) {
if (ossClient == null) {
ossClient = new OSSClientBuilder().build(
ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret());
}
}
}
return ossClient;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
if (getOssClient() == null) {
return Mono.error(new IllegalStateException("OSS 未配置或未启用"));
}
String originalFilename = filePart.filename();
String fileExtension = getFileExtension(originalFilename);
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
return Mono.error(new IllegalArgumentException(
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
}
String objectName = ossProperties.getBasePath() + "/" + directory + "/"
+ directory + "_" + UUID.randomUUID().toString() + fileExtension;
String bucketName = ossProperties.getBucketName();
return Mono.fromCallable(() -> {
Path tempFile = Files.createTempFile("oss-upload-", fileExtension);
return tempFile;
}).flatMap(tempFile -> filePart.transferTo(tempFile.toFile()).thenReturn(tempFile))
.flatMap(tempFile -> {
try {
long fileSize = Files.size(tempFile);
if (fileSize > MAX_FILE_SIZE) {
Files.deleteIfExists(tempFile);
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
}
PutObjectRequest putRequest = new PutObjectRequest(bucketName, objectName, tempFile.toFile());
getOssClient().putObject(putRequest);
// 删除临时文件
Files.deleteIfExists(tempFile);
// 生成访问URL
String fileUrl = buildAccessUrl(objectName);
return Mono.just(fileUrl);
} catch (Exception e) {
try {
Files.deleteIfExists(tempFile);
} catch (Exception ignored) {
}
return Mono.error(new RuntimeException("OSS 上传失败: " + e.getMessage(), e));
}
});
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
if (getOssClient() == null) {
return Mono.empty();
}
return Mono.fromRunnable(() -> {
try {
String objectName = extractObjectName(fileUrl);
if (objectName != null) {
getOssClient().deleteObject(ossProperties.getBucketName(), objectName);
}
} catch (Exception e) {
System.err.println("Failed to delete OSS file: " + fileUrl + ", error: " + e.getMessage());
}
});
}
/**
* 构建文件访问 URL
* 优先使用自定义域名/CDN域名,否则使用 OSS 默认域名
*/
private String buildAccessUrl(String objectName) {
String domain;
if (ossProperties.getCustomDomain() != null && !ossProperties.getCustomDomain().isBlank()) {
domain = ossProperties.getCustomDomain();
if (domain.endsWith("/")) {
domain = domain.substring(0, domain.length() - 1);
}
} else {
domain = "https://" + ossProperties.getBucketName() + "." + ossProperties.getEndpoint();
}
return domain + "/" + objectName;
}
/**
* 从文件 URL 中提取 OSS Object Name
*/
private String extractObjectName(String fileUrl) {
String basePath = ossProperties.getBasePath();
int idx = fileUrl.indexOf(basePath);
if (idx >= 0) {
return fileUrl.substring(idx);
}
return null;
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".png";
}
return filename.substring(filename.lastIndexOf("."));
}
}
@@ -0,0 +1,206 @@
package cn.novalon.gym.manage.brand.handler;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
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.codec.multipart.FilePart;
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.time.LocalDateTime;
import java.util.Map;
/**
* 品牌配置 HTTP Handler
* <p>
* tenantId 从 JWT Token 中提取,不再通过 URL 路径参数传递,
* 确保每个用户只能操作自己租户的品牌配置。
*
* @author 张翔
* @date 2026-07-23
*/
@Component
@Tag(name = "品牌定制", description = "Logo上传、背景图上传、品牌配色设置、实时预览")
public class BrandConfigHandler {
private final IBrandConfigService brandConfigService;
private final AuthUtil authUtil;
public BrandConfigHandler(IBrandConfigService brandConfigService, AuthUtil authUtil) {
this.brandConfigService = brandConfigService;
this.authUtil = authUtil;
}
@Operation(summary = "获取品牌配置", description = "根据当前租户获取品牌配置信息")
public Mono<ServerResponse> getBrandConfig(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.getBrandConfig(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of(
"message", "未找到品牌配置,将使用默认配置"
)));
}
@Operation(summary = "上传Logo", description = "上传品牌Logo图片,支持PNG/JPG格式,限制2MB以内")
public Mono<ServerResponse> uploadLogo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.multipartData()
.flatMap(multipartData -> {
var part = multipartData.getFirst("file");
if (part == null) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
}
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
}
return brandConfigService.uploadLogo(tenantId, filePart)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
})
.switchIfEmpty(ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
@Operation(summary = "上传背景图", description = "上传品牌背景图,支持PNG/JPG格式,限制2MB以内")
public Mono<ServerResponse> uploadBackgroundImage(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.multipartData()
.flatMap(multipartData -> {
var part = multipartData.getFirst("file");
if (part == null) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
}
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
}
return brandConfigService.uploadBackgroundImage(tenantId, filePart)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
})
.switchIfEmpty(ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
@Operation(summary = "更新品牌配色", description = "设置品牌主色调、辅助色等配色方案")
public Mono<ServerResponse> updateColorConfig(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.bodyToMono(Map.class)
.map(this::mapToBrandConfig)
.flatMap(config -> brandConfigService.updateColorConfig(tenantId, config))
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage(),
"timestamp", LocalDateTime.now()
))
);
}
@Operation(summary = "删除Logo", description = "删除品牌Logo,恢复默认")
public Mono<ServerResponse> removeLogo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.removeLogo(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
}
@Operation(summary = "删除背景图", description = "删除品牌背景图,恢复默认")
public Mono<ServerResponse> removeBackgroundImage(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.removeBackgroundImage(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
}
@Operation(summary = "更新品牌信息", description = "设置品牌名称和口号")
public Mono<ServerResponse> updateBrandInfo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.bodyToMono(Map.class)
.map(body -> {
BrandConfig config = new BrandConfig();
if (body.containsKey("brandName")) {
String name = (String) body.get("brandName");
if (name.length() > 100) {
throw new IllegalArgumentException("品牌名称不能超过100个字符");
}
config.setBrandName(name);
}
if (body.containsKey("slogan")) {
String slogan = (String) body.get("slogan");
if (slogan.length() > 200) {
throw new IllegalArgumentException("口号不能超过200个字符");
}
config.setSlogan(slogan);
}
return config;
})
.flatMap(config -> brandConfigService.updateBrandInfo(tenantId, config))
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
/**
* 将前端传来的 Map 转换为 BrandConfig(仅用于颜色配置)
*/
private BrandConfig mapToBrandConfig(Map<String, Object> body) {
BrandConfig config = new BrandConfig();
if (body.containsKey("primaryColor")) {
String color = (String) body.get("primaryColor");
validateHexColor(color);
config.setPrimaryColor(color);
}
if (body.containsKey("primaryColorRgb")) {
config.setPrimaryColorRgb((String) body.get("primaryColorRgb"));
}
if (body.containsKey("secondaryColor")) {
String color = (String) body.get("secondaryColor");
validateHexColor(color);
config.setSecondaryColor(color);
}
if (body.containsKey("secondaryColorRgb")) {
config.setSecondaryColorRgb((String) body.get("secondaryColorRgb"));
}
if (body.containsKey("fontFamily")) {
config.setFontFamily((String) body.get("fontFamily"));
}
return config;
}
/**
* 验证HEX颜色格式
*/
private void validateHexColor(String color) {
if (color == null) {
return;
}
if (!color.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")) {
throw new IllegalArgumentException("无效的HEX颜色格式: " + color + ",正确格式如 #1E90FF");
}
}
}
@@ -0,0 +1,207 @@
package cn.novalon.gym.manage.brand.websocket;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 品牌配置实时预览 WebSocket 处理器
*
* @author 张翔
* @date 2026-07-23
*/
@Component
public class BrandWebSocketHandler implements WebSocketHandler {
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Map<String, LocalDateTime> lastActivityTime = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private final JwtTokenProvider jwtTokenProvider;
@Value("${websocket.idle-timeout:300s}")
private Duration idleTimeout;
@Value("${websocket.heartbeat-interval:30s}")
private Duration heartbeatInterval;
public BrandWebSocketHandler(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public Mono<Void> handle(WebSocketSession session) {
String tenantId = extractTenantId(session);
sessions.put(tenantId, session);
lastActivityTime.put(tenantId, LocalDateTime.now());
return session.receive()
.doOnNext(message -> {
String payload = message.getPayloadAsText();
handleIncomingMessage(session, tenantId, payload);
lastActivityTime.put(tenantId, LocalDateTime.now());
})
.doOnComplete(() -> {
sessions.remove(tenantId);
lastActivityTime.remove(tenantId);
})
.doOnError(error -> {
sessions.remove(tenantId);
lastActivityTime.remove(tenantId);
})
.then();
}
@Scheduled(fixedRate = 60000)
public void cleanupIdleConnections() {
LocalDateTime now = LocalDateTime.now();
lastActivityTime.entrySet().removeIf(entry -> {
if (Duration.between(entry.getValue(), now).compareTo(idleTimeout) > 0) {
String tenantId = entry.getKey();
WebSocketSession session = sessions.remove(tenantId);
if (session != null && session.isOpen()) {
session.close().subscribe();
}
return true;
}
return false;
});
}
@Scheduled(fixedRate = 30000)
public void sendHeartbeat() {
sessions.forEach((tenantId, session) -> {
if (session.isOpen()) {
try {
String heartbeat = objectMapper.writeValueAsString(Map.of(
"type", "heartbeat",
"timestamp", System.currentTimeMillis()
));
session.send(Mono.just(session.textMessage(heartbeat))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS heartbeat error: " + e.getMessage());
}
}
});
}
/**
* 向指定租户推送品牌配置更新
*/
public void sendBrandUpdate(String tenantId, BrandConfig config) {
WebSocketSession session = sessions.get(tenantId);
if (session != null && session.isOpen()) {
try {
Map<String, Object> message = Map.of(
"type", "brandUpdate",
"data", config,
"timestamp", System.currentTimeMillis()
);
String json = objectMapper.writeValueAsString(message);
session.send(Mono.just(session.textMessage(json))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS send error: " + e.getMessage());
}
}
}
/**
* 广播品牌配置更新给所有连接的租户
*/
public void broadcastBrandUpdate(BrandConfig config) {
// 仅推送给对应租户
if (config.getTenantId() != null) {
sendBrandUpdate(config.getTenantId(), config);
}
}
/**
* 从 WebSocket 握手中提取租户ID
* <ol>
* <li>优先从 Authorization Header 的 JWT Token 中提取</li>
* <li>回退到 URL 查询参数 tenantId</li>
* <li>兜底使用 session ID</li>
* </ol>
*/
private String extractTenantId(WebSocketSession session) {
// 1. 优先从 JWT Token 提取
String tenantId = extractTenantIdFromJwt(session);
if (tenantId != null) {
return tenantId;
}
// 2. 回退到查询参数(兼容旧版)
String query = session.getHandshakeInfo().getUri().getQuery();
if (query != null && query.contains("tenantId=")) {
return query.split("tenantId=")[1].split("&")[0];
}
// 3. 兜底
return session.getId();
}
/**
* 从 WebSocket 握手的 Authorization Header 中提取 JWT Token 的 tenantId
*/
private String extractTenantIdFromJwt(WebSocketSession session) {
try {
var headers = session.getHandshakeInfo().getHeaders();
String authHeader = headers.getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtTokenProvider.validateToken(token)) {
return jwtTokenProvider.getTenantIdFromToken(token);
}
}
} catch (Exception e) {
System.err.println("Brand WS: Failed to extract tenantId from JWT: " + e.getMessage());
}
return null;
}
private void handleIncomingMessage(WebSocketSession session, String tenantId, String payload) {
try {
Map<String, Object> message = objectMapper.readValue(payload,
new TypeReference<Map<String, Object>>() {});
String type = (String) message.get("type");
switch (type) {
case "ping":
sendPong(session);
break;
case "subscribe":
sessions.put(tenantId, session);
lastActivityTime.put(tenantId, LocalDateTime.now());
break;
default:
break;
}
} catch (Exception e) {
System.err.println("Brand WS message error: " + e.getMessage());
}
}
private void sendPong(WebSocketSession session) {
try {
String pong = objectMapper.writeValueAsString(Map.of(
"type", "pong",
"timestamp", System.currentTimeMillis()
));
session.send(Mono.just(session.textMessage(pong))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS pong error: " + e.getMessage());
}
}
}
@@ -0,0 +1 @@
cn.novalon.gym.manage.brand.config.BrandWebSocketConfig
@@ -0,0 +1,296 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandConfigServiceImplTest {
@Mock
private IBrandConfigRepository brandConfigRepository;
@Mock
private FileStorageService fileStorageService;
@Mock
private BrandWebSocketHandler brandWebSocketHandler;
@Mock
private FilePart filePart;
private BrandConfigServiceImpl brandConfigService;
private static final String TENANT_ID = "tenant-001";
@BeforeEach
void setUp() {
brandConfigService = new BrandConfigServiceImpl(
brandConfigRepository, fileStorageService, brandWebSocketHandler);
}
// ==================== getBrandConfig ====================
@Test
void getBrandConfig_shouldReturnExistingConfig() {
BrandConfig existingConfig = createTestConfig();
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.just(existingConfig));
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config).isNotNull();
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
})
.verifyComplete();
verify(brandConfigRepository).findByTenantId(TENANT_ID);
verify(brandConfigRepository, never()).save(any());
}
@Test
void getBrandConfig_shouldCreateDefaultWhenNotFound() {
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
assertThat(config.getPrimaryColorRgb()).isEqualTo("0,230,118");
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A");
assertThat(config.getLogoUrl()).isNull();
})
.verifyComplete();
verify(brandConfigRepository).findByTenantId(TENANT_ID);
verify(brandConfigRepository).save(any(BrandConfig.class));
}
// ==================== uploadLogo ====================
@Test
void uploadLogo_shouldUploadAndSaveConfig() {
BrandConfig existingConfig = createTestConfig();
String newLogoUrl = "https://example.com/new-logo.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
})
.verifyComplete();
verify(fileStorageService).uploadImage(filePart, "logo");
verify(brandConfigRepository, atLeastOnce()).save(any(BrandConfig.class));
}
@Test
void uploadLogo_shouldCreateConfigWhenTenantNotFound() {
String newLogoUrl = "https://example.com/new-logo.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
lenient().when(fileStorageService.deleteFile(any())).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
})
.verifyComplete();
}
@Test
void uploadLogo_shouldPropagateStorageError() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "logo"))
.thenReturn(Mono.error(new RuntimeException("Storage error")));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
// ==================== uploadBackgroundImage ====================
@Test
void uploadBackgroundImage_shouldUploadAndSaveConfig() {
BrandConfig existingConfig = createTestConfig();
String newBgUrl = "https://example.com/new-bg.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "background")).thenReturn(Mono.just(newBgUrl));
lenient().when(fileStorageService.deleteFile(anyString())).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadBackgroundImage(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getBackgroundImageUrl()).isEqualTo(newBgUrl);
})
.verifyComplete();
verify(fileStorageService).uploadImage(filePart, "background");
}
// ==================== updateColorConfig ====================
@Test
void updateColorConfig_shouldUpdateAllColorFields() {
BrandConfig existingConfig = createTestConfig();
BrandConfig updateConfig = new BrandConfig();
updateConfig.setPrimaryColor("#FF0000");
updateConfig.setPrimaryColorRgb("255,0,0");
updateConfig.setSecondaryColor("#00FF00");
updateConfig.setSecondaryColorRgb("0,255,0");
updateConfig.setFontFamily("Arial");
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
assertThat(config.getPrimaryColorRgb()).isEqualTo("255,0,0");
assertThat(config.getSecondaryColor()).isEqualTo("#00FF00");
assertThat(config.getSecondaryColorRgb()).isEqualTo("0,255,0");
assertThat(config.getFontFamily()).isEqualTo("Arial");
})
.verifyComplete();
}
@Test
void updateColorConfig_shouldOnlyUpdateProvidedFields() {
BrandConfig existingConfig = createTestConfig();
BrandConfig updateConfig = new BrandConfig();
updateConfig.setPrimaryColor("#FF0000");
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A"); // unchanged
assertThat(config.getLogoUrl()).isEqualTo("https://example.com/logo.png"); // unchanged
})
.verifyComplete();
}
// ==================== removeLogo ====================
@Test
void removeLogo_shouldClearLogoUrl() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.removeLogo(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isNull();
})
.verifyComplete();
verify(fileStorageService).deleteFile("https://example.com/logo.png");
}
// ==================== removeBackgroundImage ====================
@Test
void removeBackgroundImage_shouldClearBackgroundImageUrl() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.deleteFile("https://example.com/bg.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.removeBackgroundImage(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getBackgroundImageUrl()).isNull();
})
.verifyComplete();
verify(fileStorageService).deleteFile("https://example.com/bg.png");
}
// ==================== helper ====================
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId(TENANT_ID);
config.setLogoUrl("https://example.com/logo.png");
config.setBackgroundImageUrl("https://example.com/bg.png");
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return config;
}
}
@@ -0,0 +1,170 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DualFileStorageServiceTest {
@Mock
private FileStorageService ossStorage;
@Mock
private FileStorageService localStorage;
@Mock
private FilePart filePart;
private OssProperties ossProperties;
private DualFileStorageService dualService;
@BeforeEach
void setUp() {
ossProperties = new OssProperties();
// 默认不启用 OSS
ossProperties.setEnabled(false);
dualService = new DualFileStorageService(ossStorage, localStorage, ossProperties);
}
// ==================== OSS 未启用时,直接走本地存储 ====================
@Test
void shouldUseLocalStorageWhenOssDisabled() {
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_test.png";
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
.verifyComplete();
verify(localStorage).uploadImage(filePart, "logo");
verify(ossStorage, never()).uploadImage(any(), anyString());
}
@Test
void shouldDeleteFromLocalOnlyWhenOssDisabled() {
String fileUrl = "http://localhost/api/files/preview/brand/logo/test.png";
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(localStorage).deleteFile(fileUrl);
verify(ossStorage, never()).deleteFile(anyString());
}
// ==================== OSS 启用时,优先 OSS ====================
@Test
void shouldUseOssWhenEnabledAndConfigured() {
configureOss();
String ossUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/logo_test.png";
when(ossStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(ossUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(ossUrl))
.verifyComplete();
verify(ossStorage).uploadImage(filePart, "logo");
verify(localStorage, never()).uploadImage(any(), anyString());
}
@Test
void shouldFallbackToLocalWhenOssFails() {
configureOss();
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_fallback.png";
when(ossStorage.uploadImage(filePart, "logo"))
.thenReturn(Mono.error(new RuntimeException("OSS unavailable")));
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
.verifyComplete();
verify(ossStorage).uploadImage(filePart, "logo");
verify(localStorage).uploadImage(filePart, "logo");
}
@Test
void shouldDeleteFromBothWhenOssEnabled() {
configureOss();
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
when(ossStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(ossStorage).deleteFile(fileUrl);
verify(localStorage).deleteFile(fileUrl);
}
@Test
void shouldDeleteLocalEvenWhenOssDeleteFails() {
configureOss();
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
when(ossStorage.deleteFile(fileUrl))
.thenReturn(Mono.error(new RuntimeException("OSS delete failed")));
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(ossStorage).deleteFile(fileUrl);
verify(localStorage).deleteFile(fileUrl);
}
// ==================== 边界情况 ====================
@Test
void deleteFile_shouldHandleNullUrl() {
Mono<Void> result = dualService.deleteFile(null);
StepVerifier.create(result).verifyComplete();
verify(localStorage, never()).deleteFile(any());
verify(ossStorage, never()).deleteFile(any());
}
@Test
void deleteFile_shouldHandleEmptyUrl() {
Mono<Void> result = dualService.deleteFile("");
StepVerifier.create(result).verifyComplete();
verify(localStorage, never()).deleteFile(any());
verify(ossStorage, never()).deleteFile(any());
}
// ==================== helper ====================
private void configureOss() {
ossProperties.setEnabled(true);
ossProperties.setEndpoint("oss-cn-hangzhou.aliyuncs.com");
ossProperties.setAccessKeyId("test-access-key");
ossProperties.setAccessKeySecret("test-access-secret");
ossProperties.setBucketName("test-bucket");
}
}
@@ -0,0 +1,182 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.io.File;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LocalFileStorageServiceTest {
@TempDir
Path tempDir;
@Mock
private FilePart filePart;
private FileStorageService localStorageService;
@BeforeEach
void setUp() {
String uploadDir = tempDir.toString();
String baseUrl = "http://localhost:8080/api/files";
localStorageService = new LocalFileStorageService(uploadDir, baseUrl);
}
@Test
void uploadImage_shouldAcceptAndSaveValidFiles() {
when(filePart.filename()).thenReturn("test-logo.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).contains("brand/logo/logo_");
assertThat(url).endsWith(".png");
})
.verifyComplete();
}
@Test
void uploadImage_shouldRejectInvalidFormat() {
when(filePart.filename()).thenReturn("document.pdf");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldRejectTxtFormat() {
when(filePart.filename()).thenReturn("script.txt");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldRejectBatchFormat() {
when(filePart.filename()).thenReturn("file.bat");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldHandleNoExtensionAsPng() {
when(filePart.filename()).thenReturn("noextension");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).endsWith(".png");
})
.verifyComplete();
}
@Test
void uploadImage_shouldGenerateCorrectUrlFormat() {
when(filePart.filename()).thenReturn("company-logo.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).startsWith("http://localhost:8080/api/files/preview/brand/logo/");
})
.verifyComplete();
}
@Test
void uploadImage_shouldUseCorrectDirectory() {
when(filePart.filename()).thenReturn("bg.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "background");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).contains("brand/background/background_");
})
.verifyComplete();
}
@Test
void uploadImage_shouldHandleTransferError() {
when(filePart.filename()).thenReturn("logo.png");
when(filePart.transferTo(any(File.class)))
.thenReturn(Mono.error(new RuntimeException("Transfer failed")));
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
@Test
void deleteFile_shouldNotThrowForNullUrl() {
Mono<Void> result = localStorageService.deleteFile(null);
StepVerifier.create(result)
.verifyComplete();
}
@Test
void deleteFile_shouldNotThrowForEmptyUrl() {
Mono<Void> result = localStorageService.deleteFile("");
StepVerifier.create(result)
.verifyComplete();
}
@Test
void deleteFile_shouldNotThrowForNonExistentFile() {
Mono<Void> result = localStorageService.deleteFile(
"http://localhost:8080/api/files/preview/brand/logo/nonexistent.png");
StepVerifier.create(result)
.verifyComplete();
}
}
@@ -0,0 +1,209 @@
package cn.novalon.gym.manage.brand.handler;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandConfigHandlerTest {
@Mock
private IBrandConfigService brandConfigService;
@Mock
private AuthUtil authUtil;
private BrandConfigHandler brandConfigHandler;
private static final String TENANT_ID = "tenant-001";
@BeforeEach
void setUp() {
brandConfigHandler = new BrandConfigHandler(brandConfigService, authUtil);
}
private MockServerRequest.Builder mockRequest() {
return MockServerRequest.builder()
.header("X-Tenant-Id", "tenant-001");
}
// ==================== getBrandConfig ====================
@Test
void getBrandConfig_shouldReturnConfig() {
BrandConfig config = createTestConfig();
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).getBrandConfig(TENANT_ID);
}
@Test
void getBrandConfig_shouldReturnOkEvenWhenNotFound() {
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.empty());
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== updateColorConfig ====================
@Test
void updateColorConfig_shouldUpdateAndReturnOk() {
BrandConfig config = createTestConfig();
config.setPrimaryColor("#FF0000");
Map<String, Object> requestBody = Map.of("primaryColor", "#FF0000");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
.thenReturn(Mono.just(config));
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void updateColorConfig_shouldRejectInvalidHex() {
Map<String, Object> requestBody = Map.of("primaryColor", "INVALID");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
.verifyComplete();
verify(brandConfigService, never()).updateColorConfig(anyString(), any());
}
@Test
void updateColorConfig_shouldAcceptShortHexFormat() {
BrandConfig config = createTestConfig();
config.setPrimaryColor("#F00");
Map<String, Object> requestBody = Map.of("primaryColor", "#F00");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
.thenReturn(Mono.just(config));
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void updateColorConfig_shouldRejectInvalidHexInSecondary() {
Map<String, Object> requestBody = Map.of("secondaryColor", "not-a-color");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
.verifyComplete();
}
// ==================== removeLogo ====================
@Test
void removeLogo_shouldRemoveAndReturnOk() {
BrandConfig config = createTestConfig();
config.setLogoUrl(null);
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.removeLogo(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.removeLogo(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).removeLogo(TENANT_ID);
}
// ==================== removeBackgroundImage ====================
@Test
void removeBackgroundImage_shouldRemoveAndReturnOk() {
BrandConfig config = createTestConfig();
config.setBackgroundImageUrl(null);
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.removeBackgroundImage(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.removeBackgroundImage(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).removeBackgroundImage(TENANT_ID);
}
// ==================== helper ====================
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId(TENANT_ID);
config.setLogoUrl("https://example.com/logo.png");
config.setBackgroundImageUrl("https://example.com/bg.png");
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return config;
}
}
@@ -0,0 +1,181 @@
package cn.novalon.gym.manage.brand.websocket;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.socket.HandshakeInfo;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.net.URI;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandWebSocketHandlerTest {
@Mock
private WebSocketSession session;
@Mock
private WebSocketMessage webSocketMessage;
@Mock
private HandshakeInfo handshakeInfo;
@Mock
private JwtTokenProvider jwtTokenProvider;
private BrandWebSocketHandler handler;
@BeforeEach
void setUp() {
handler = new BrandWebSocketHandler(jwtTokenProvider);
}
// ==================== JWT-based tenantId extraction ====================
@Test
void handle_shouldExtractTenantIdFromJwtAuthHeader() {
setupJwtAuthHeader("Bearer valid-jwt-token");
when(jwtTokenProvider.validateToken("valid-jwt-token")).thenReturn(true);
when(jwtTokenProvider.getTenantIdFromToken("valid-jwt-token")).thenReturn("tenant-from-jwt");
when(session.receive()).thenReturn(Flux.never());
// Should not throw — tenantId extracted from JWT
handler.handle(session).subscribe();
}
// ==================== Query param fallback ====================
@Test
void handle_shouldFallbackToQueryParamWhenNoJwtHeader() {
// No Authorization header
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
URI uri = URI.create("ws://localhost/ws/brand?tenantId=tenant-from-query");
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(handshakeInfo.getUri()).thenReturn(uri);
when(session.receive()).thenReturn(Flux.never());
handler.handle(session).subscribe();
}
@Test
void handle_shouldFallbackToSessionIdWhenNoQueryParamAndNoJwt() {
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
URI uri = URI.create("ws://localhost/ws/brand");
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(handshakeInfo.getUri()).thenReturn(uri);
when(session.getId()).thenReturn("session-id-123");
when(session.receive()).thenReturn(Flux.never());
handler.handle(session).subscribe();
}
// ==================== Message handling ====================
@Test
void handle_shouldProcessPingMessage() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"ping\"}");
when(session.textMessage(anyString())).thenReturn(webSocketMessage);
when(session.send(any())).thenReturn(Mono.empty());
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldProcessSubscribeMessage() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"subscribe\"}");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldIgnoreUnknownMessageType() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"unknown\"}");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldNotCrashOnInvalidJson() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("not-valid-json");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldPropagateConnectionError() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.error(new RuntimeException("Connection error")));
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyError();
}
// ==================== sendBrandUpdate ====================
@Test
void sendBrandUpdate_shouldNotFailWhenNoSession() {
BrandConfig config = createTestConfig();
// Should not throw
handler.sendBrandUpdate("nonexistent-tenant", config);
}
// ==================== helper ====================
private void setupSessionWithJwt(String tenantId) {
String token = "jwt-" + tenantId;
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token);
when(handshakeInfo.getHeaders()).thenReturn(headers);
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(jwtTokenProvider.validateToken(token)).thenReturn(true);
when(jwtTokenProvider.getTenantIdFromToken(token)).thenReturn(tenantId);
}
private void setupJwtAuthHeader(String authHeader) {
String token = authHeader.substring(7); // strip "Bearer "
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
when(handshakeInfo.getHeaders()).thenReturn(headers);
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
}
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId("tenant-001");
config.setLogoUrl("https://example.com/logo.png");
config.setPrimaryColor("#00E676");
config.setFontFamily("default");
return config;
}
}
@@ -0,0 +1,172 @@
package cn.novalon.gym.manage.checkin.handler;
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CheckInHandlerTest {
@Mock
private AuthUtil authUtil;
@Mock
private CheckServiceImpl checkService;
private CheckInHandler checkInHandler;
private static final Long MEMBER_ID = 10001L;
@BeforeEach
void setUp() {
checkInHandler = new CheckInHandler(authUtil, checkService);
}
// ==================== checkIn ====================
@Test
void checkIn_shouldReturnOk() {
Map<String, Object> body = Map.of("qrContent", "checkin:member:10001");
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
when(checkService.checkIn(MEMBER_ID, "checkin:member:10001")).thenReturn(Mono.just("签到成功"));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(body));
Mono<ServerResponse> result = checkInHandler.checkIn(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
verify(checkService).checkIn(MEMBER_ID, "checkin:member:10001");
}
@Test
void checkIn_shouldReturnBadRequestOnError() {
Map<String, Object> body = Map.of("qrContent", "invalid-content");
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
when(checkService.checkIn(MEMBER_ID, "invalid-content"))
.thenReturn(Mono.error(new RuntimeException("Invalid QR code")));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(body));
Mono<ServerResponse> result = checkInHandler.checkIn(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
// ==================== getQRCode ====================
@Test
void getQRCode_shouldReturnOkWithQRCode() {
QRCodeVo qrCode = new QRCodeVo("base64content", false, "qr-content", 200, 200, LocalDate.now());
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
when(checkService.getQRCode(MEMBER_ID)).thenReturn(Mono.just(qrCode));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = checkInHandler.getQRCode(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getSignInRecords ====================
@Test
void getSignInRecords_shouldReturnOkWithRecords() {
List<SignInRecordVO> records = List.of(createTestRecord());
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
when(checkService.getSignInRecords(eq(MEMBER_ID), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(Flux.fromIterable(records));
MockServerRequest request = MockServerRequest.builder()
.queryParam("startDate", "2025-01-01")
.queryParam("endDate", "2025-01-31")
.build();
Mono<ServerResponse> result = checkInHandler.getSignInRecords(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getSignInStatistics ====================
@Test
void getSignInStatistics_shouldReturnOkWithStats() {
SignInStatsVO stats = new SignInStatsVO();
when(authUtil.getMemberIdOrThrow(any())).thenReturn(MEMBER_ID);
when(checkService.getSignInStats(eq(MEMBER_ID), any(LocalDate.class), any(LocalDate.class)))
.thenReturn(Mono.just(stats));
MockServerRequest request = MockServerRequest.builder()
.queryParam("startDate", "2025-01-01")
.queryParam("endDate", "2025-01-31")
.build();
Mono<ServerResponse> result = checkInHandler.getSignInStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getDailySignInStats ====================
@Test
void getDailySignInStats_shouldReturnOkWithDailyStats() {
SignInStatsVO stats = new SignInStatsVO();
when(checkService.getDailySignInStats(any(LocalDate.class))).thenReturn(Mono.just(stats));
MockServerRequest request = MockServerRequest.builder()
.queryParam("date", "2025-01-15")
.build();
Mono<ServerResponse> result = checkInHandler.getDailySignInStats(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== helper ====================
private SignInRecordVO createTestRecord() {
SignInRecordVO record = new SignInRecordVO();
record.setId(1L);
record.setMemberId(MEMBER_ID);
record.setSignInTime(LocalDateTime.now());
record.setSignInType("QR_CODE");
record.setSignInStatus("SUCCESS");
return record;
}
}
@@ -0,0 +1,176 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import jakarta.validation.Validator;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CoachHandlerTest {
@Mock
private CoachCourseService coachCourseService;
@Mock
private Validator validator;
private CoachHandler coachHandler;
@BeforeEach
void setUp() {
coachHandler = new CoachHandler(coachCourseService, validator);
}
// ==================== getAllCoaches ====================
@Test
void getAllCoaches_shouldReturnOkWithCoachList() {
SysUser coach = mock(SysUser.class);
when(coachCourseService.getAllCoaches()).thenReturn(Flux.just(coach));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = coachHandler.getAllCoaches(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
verify(coachCourseService).getAllCoaches();
}
@Test
void getAllCoaches_shouldReturnOkWhenEmptyList() {
when(coachCourseService.getAllCoaches()).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = coachHandler.getAllCoaches(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getCoachCourses ====================
@Test
void getCoachCourses_shouldReturnOkWithCourses() {
GroupCourse course = mock(GroupCourse.class);
when(coachCourseService.getCoachCourses(anyLong())).thenReturn(Flux.just(course));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = coachHandler.getCoachCourses(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getCoachCourses_shouldReturnOkWhenEmpty() {
when(coachCourseService.getCoachCourses(anyLong())).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = coachHandler.getCoachCourses(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== disableCoach ====================
@Test
void disableCoach_shouldReturnOkWhenDisabled() {
when(coachCourseService.disableCoach(anyLong())).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = coachHandler.disableCoach(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void disableCoach_shouldReturnBadRequestOnError() {
when(coachCourseService.disableCoach(anyLong()))
.thenReturn(Mono.error(new RuntimeException("Coach not found")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = coachHandler.disableCoach(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
// ==================== getViolationCounts ====================
@Test
void getViolationCounts_shouldReturnOk() {
when(coachCourseService.getViolationCounts()).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = coachHandler.getViolationCounts(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getCoachViolations ====================
@Test
void getCoachViolations_shouldReturnOkWithViolations() {
when(coachCourseService.getCoachViolations(anyLong())).thenReturn(Flux.just(Map.of("violationId", 1, "reason", "迟到")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = coachHandler.getCoachViolations(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getCoachViolations_shouldReturnEmptyListWhenNone() {
when(coachCourseService.getCoachViolations(anyLong())).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = coachHandler.getCoachViolations(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
}
@@ -0,0 +1,219 @@
package cn.novalon.gym.manage.datacount.handler;
import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DataStatisticsHandlerTest {
@Mock
private IDataStatisticsService dataStatisticsService;
private DataStatisticsHandler handler;
@BeforeEach
void setUp() throws Exception {
handler = new DataStatisticsHandler();
java.lang.reflect.Field field = DataStatisticsHandler.class.getDeclaredField("dataStatisticsService");
field.setAccessible(true);
field.set(handler, dataStatisticsService);
}
// ==================== getStatisticsSummary ====================
@Test
void getStatisticsSummary_shouldReturnOkWithSummary() {
StatisticsSummary summary = createTestSummary();
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class))).thenReturn(Mono.just(summary));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "DAY")
.build();
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getStatisticsSummary_shouldReturnOkEvenOnError() {
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class)))
.thenReturn(Mono.error(new RuntimeException("Service error")));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "DAY")
.build();
// Error handler returns empty/default summary with 200 OK
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getMemberStatistics ====================
@Test
void getMemberStatistics_shouldReturnOk() {
MemberStatistics stats = new MemberStatistics();
when(dataStatisticsService.getMemberStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "WEEK")
.build();
Mono<ServerResponse> result = handler.getMemberStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getMemberStatistics_shouldReturnOkEvenOnError() {
when(dataStatisticsService.getMemberStatistics(any(StatisticsQuery.class)))
.thenReturn(Mono.error(new RuntimeException("Service error")));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "WEEK")
.build();
Mono<ServerResponse> result = handler.getMemberStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getBookingStatistics ====================
@Test
void getBookingStatistics_shouldReturnOk() {
BookingStatistics stats = new BookingStatistics();
when(dataStatisticsService.getBookingStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "MONTH")
.build();
Mono<ServerResponse> result = handler.getBookingStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getSignInStatistics ====================
@Test
void getSignInStatistics_shouldReturnOk() {
SignInStatistics stats = new SignInStatistics();
when(dataStatisticsService.getSignInStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(stats));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "MONTH")
.build();
Mono<ServerResponse> result = handler.getSignInStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== queryHistoricalStatistics ====================
@Test
void queryHistoricalStatistics_shouldReturnOkWithList() {
when(dataStatisticsService.queryHistoricalStatistics(any(StatisticsQuery.class)))
.thenReturn(Flux.just(createTestDataStatistics()));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "YEAR")
.build();
Mono<ServerResponse> result = handler.queryHistoricalStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void queryHistoricalStatistics_shouldReturnOkWhenEmpty() {
when(dataStatisticsService.queryHistoricalStatistics(any(StatisticsQuery.class)))
.thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "YEAR")
.build();
Mono<ServerResponse> result = handler.queryHistoricalStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== exportStatistics ====================
@Test
void exportStatistics_shouldReturnOkWithExcelContent() {
byte[] excelData = "mock-excel-content".getBytes();
when(dataStatisticsService.exportStatistics(any(StatisticsQuery.class))).thenReturn(Mono.just(excelData));
MockServerRequest request = MockServerRequest.builder()
.queryParam("periodType", "MONTH")
.build();
Mono<ServerResponse> result = handler.exportStatistics(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== buildQueryFromRequest (via parameterized tests) ====================
@Test
void getStatisticsSummary_shouldUseDefaultPeriodWhenMissing() {
StatisticsSummary summary = createTestSummary();
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class))).thenReturn(Mono.just(summary));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = handler.getStatisticsSummary(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== helper ====================
private StatisticsSummary createTestSummary() {
StatisticsSummary summary = new StatisticsSummary();
summary.setMemberStatistics(new MemberStatistics());
summary.setBookingStatistics(new BookingStatistics());
summary.setSignInStatistics(new SignInStatistics());
summary.setCoachStatistics(new CoachStatistics());
return summary;
}
private DataStatistics createTestDataStatistics() {
return DataStatistics.builder()
.statType("MEMBER")
.periodType("DAY")
.build();
}
}
@@ -0,0 +1,227 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GroupCourseHandlerTest {
@Mock
private IGroupCourseService groupCourseService;
@Mock
private Validator validator;
@Mock
private RedisUtil redisUtil;
@Mock
private ObjectMapper objectMapper;
private GroupCourseHandler handler;
@BeforeEach
void setUp() {
handler = new GroupCourseHandler(groupCourseService, validator, redisUtil, objectMapper);
}
// ==================== getAllGroupCourse ====================
@Test
void getAllGroupCourse_shouldReturnOkWithCourses() {
GroupCourseVO vo1 = mock(GroupCourseVO.class);
GroupCourseVO vo2 = mock(GroupCourseVO.class);
when(groupCourseService.findAllAsVO(false)).thenReturn(Flux.just(vo1, vo2));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = handler.getAllGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
verify(groupCourseService).findAllAsVO(false);
}
@Test
void getAllGroupCourse_shouldReturnOkWhenEmpty() {
when(groupCourseService.findAllAsVO(false)).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = handler.getAllGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== getGroupCourseById ====================
@Test
void getGroupCourseById_shouldReturnOkWhenFound() {
GroupCourse course = createTestCourse(1L, "瑜伽课");
when(groupCourseService.findById(1L)).thenReturn(Mono.just(course));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.getGroupCourseById(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getGroupCourseById_shouldReturnNotFound() {
when(groupCourseService.findById(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.getGroupCourseById(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// ==================== getGroupCourseDetailById ====================
@Test
void getGroupCourseDetailById_shouldReturnOkWhenFound() {
GroupCourseDetail detail = mock(GroupCourseDetail.class);
when(groupCourseService.findDetailById(1L)).thenReturn(Mono.just(detail));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.getGroupCourseDetailById(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void getGroupCourseDetailById_shouldReturnNotFound() {
when(groupCourseService.findDetailById(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.getGroupCourseDetailById(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// ==================== cancelGroupCourse ====================
@Test
void cancelGroupCourse_shouldReturnOkWhenCancelled() {
GroupCourse cancelled = createTestCourse(1L, "瑜伽课");
cancelled.setStatus(2L);
when(groupCourseService.cancel(1L)).thenReturn(Mono.just(cancelled));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.cancelGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void cancelGroupCourse_shouldReturnNotFound() {
when(groupCourseService.cancel(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.cancelGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// ==================== deleteGroupCourse ====================
@Test
void deleteGroupCourse_shouldReturnOkWhenDeleted() {
when(groupCourseService.delete(1L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.deleteGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void deleteGroupCourse_shouldReturnNotFound() {
when(groupCourseService.delete(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.deleteGroupCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== signIn ====================
@Test
void signIn_shouldReturnOk() {
GroupCourse course = createTestCourse(1L, "瑜伽课");
when(validator.validate(any())).thenReturn(java.util.Collections.emptySet());
when(groupCourseService.signIn(eq(1L), eq(10001L))).thenReturn(Mono.just(course));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("courseId", "1")
.body(Mono.just(java.util.Map.of("memberId", 10001L, "courseId", 1L)));
Mono<ServerResponse> result = handler.signIn(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
}
// ==================== helper ====================
private GroupCourse createTestCourse(Long id, String courseName) {
GroupCourse course = new GroupCourse();
course.setId(id);
course.setCourseName(courseName);
course.setCourseType(1L);
course.setCoachId(1L);
course.setStartTime(LocalDateTime.now().plusDays(1));
course.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1));
course.setLocation("101室");
course.setMaxMembers(20);
course.setCurrentMembers(5);
course.setStatus(0L);
return course;
}
}
+6
View File
@@ -175,6 +175,12 @@
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-brand</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
@@ -20,6 +20,7 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.brand.handler.BrandConfigHandler;
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler;
import cn.novalon.gym.manage.coach.handler.CoachHandler;
import cn.novalon.gym.manage.datacount.handler.CoachPerformanceHandler;
@@ -88,6 +89,7 @@ public class SystemRouter {
DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler,
BrandConfigHandler brandConfigHandler,
CoachHandler coachHandler,
CoachCourseHandler coachCourseHandler,
CoachPerformanceHandler coachPerformanceHandler) {
@@ -241,7 +243,7 @@ public class SystemRouter {
.GET("/api/files/{id}/download", fileHandler::downloadFile)
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName)
.GET("/api/files/{id}/preview", fileHandler::previewFile)
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.GET("/api/files/preview/{*fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile)
// ========== 权限路由 ==========
@@ -429,6 +431,17 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ========================================
// ========== 品牌定制模块路由 ============
// ========================================
.GET("/api/brand", brandConfigHandler::getBrandConfig)
.POST("/api/brand/logo", brandConfigHandler::uploadLogo)
.DELETE("/api/brand/logo", brandConfigHandler::removeLogo)
.POST("/api/brand/background", brandConfigHandler::uploadBackgroundImage)
.DELETE("/api/brand/background", brandConfigHandler::removeBackgroundImage)
.PUT("/api/brand/color", brandConfigHandler::updateColorConfig)
.PUT("/api/brand/info", brandConfigHandler::updateBrandInfo)
.build();
}
}
+5
View File
@@ -27,6 +27,11 @@
<artifactId>manage-notify</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-brand</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-file</artifactId>
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.db.converter;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
import org.springframework.stereotype.Component;
/**
* 品牌配置实体转换器
*
* @author 张翔
* @date 2026-07-23
*/
@Component
public class BrandConfigConverter {
public BrandConfig toDomain(BrandConfigEntity entity) {
if (entity == null) {
return null;
}
BrandConfig domain = new BrandConfig();
domain.setId(entity.getId());
domain.setTenantId(entity.getTenantId());
domain.setLogoUrl(entity.getLogoUrl());
domain.setBackgroundImageUrl(entity.getBackgroundImageUrl());
domain.setPrimaryColor(entity.getPrimaryColor());
domain.setPrimaryColorRgb(entity.getPrimaryColorRgb());
domain.setSecondaryColor(entity.getSecondaryColor());
domain.setSecondaryColorRgb(entity.getSecondaryColorRgb());
domain.setFontFamily(entity.getFontFamily());
domain.setBrandName(entity.getBrandName());
domain.setSlogan(entity.getSlogan());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());
domain.setDeletedAt(entity.getDeletedAt());
return domain;
}
public BrandConfigEntity toEntity(BrandConfig domain) {
if (domain == null) {
return null;
}
BrandConfigEntity entity = new BrandConfigEntity();
entity.setId(domain.getId());
entity.setTenantId(domain.getTenantId());
entity.setLogoUrl(domain.getLogoUrl());
entity.setBackgroundImageUrl(domain.getBackgroundImageUrl());
entity.setPrimaryColor(domain.getPrimaryColor());
entity.setPrimaryColorRgb(domain.getPrimaryColorRgb());
entity.setSecondaryColor(domain.getSecondaryColor());
entity.setSecondaryColorRgb(domain.getSecondaryColorRgb());
entity.setFontFamily(domain.getFontFamily());
entity.setBrandName(domain.getBrandName());
entity.setSlogan(domain.getSlogan());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
entity.setDeletedAt(domain.getDeletedAt());
return entity;
}
}
@@ -0,0 +1,20 @@
package cn.novalon.gym.manage.db.dao;
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
/**
* 品牌配置数据访问接口
*
* @author 张翔
* @date 2026-07-23
*/
@Repository
public interface BrandConfigDao extends R2dbcRepository<BrandConfigEntity, Long> {
@Query("SELECT * FROM brand_config WHERE tenant_id = $1 AND deleted_at IS NULL")
Mono<BrandConfigEntity> findByTenantIdAndDeletedAtIsNull(String tenantId);
}
@@ -0,0 +1,88 @@
package cn.novalon.gym.manage.db.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
/**
* 品牌配置数据库实体类
*
* @author 张翔
* @date 2026-07-23
*/
@Table("brand_config")
public class BrandConfigEntity {
@Id
private Long id;
@Column("tenant_id")
private String tenantId;
@Column("logo_url")
private String logoUrl;
@Column("background_image_url")
private String backgroundImageUrl;
@Column("primary_color")
private String primaryColor;
@Column("primary_color_rgb")
private String primaryColorRgb;
@Column("secondary_color")
private String secondaryColor;
@Column("secondary_color_rgb")
private String secondaryColorRgb;
@Column("font_family")
private String fontFamily;
@Column("brand_name")
private String brandName;
@Column("slogan")
private String slogan;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Column("deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public String getBackgroundImageUrl() { return backgroundImageUrl; }
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
public String getPrimaryColor() { return primaryColor; }
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
public String getPrimaryColorRgb() { return primaryColorRgb; }
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
public String getSecondaryColor() { return secondaryColor; }
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
public String getSecondaryColorRgb() { return secondaryColorRgb; }
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
public String getFontFamily() { return fontFamily; }
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
public String getBrandName() { return brandName; }
public void setBrandName(String brandName) { this.brandName = brandName; }
public String getSlogan() { return slogan; }
public void setSlogan(String slogan) { this.slogan = slogan; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}
@@ -0,0 +1,38 @@
package cn.novalon.gym.manage.db.repository;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.db.converter.BrandConfigConverter;
import cn.novalon.gym.manage.db.dao.BrandConfigDao;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
/**
* 品牌配置仓储实现类
*
* @author 张翔
* @date 2026-07-23
*/
@Repository
public class BrandConfigRepository implements IBrandConfigRepository {
private final BrandConfigDao brandConfigDao;
private final BrandConfigConverter brandConfigConverter;
public BrandConfigRepository(BrandConfigDao brandConfigDao, BrandConfigConverter brandConfigConverter) {
this.brandConfigDao = brandConfigDao;
this.brandConfigConverter = brandConfigConverter;
}
@Override
public Mono<BrandConfig> findByTenantId(String tenantId) {
return brandConfigDao.findByTenantIdAndDeletedAtIsNull(tenantId)
.map(brandConfigConverter::toDomain);
}
@Override
public Mono<BrandConfig> save(BrandConfig brandConfig) {
return brandConfigDao.save(brandConfigConverter.toEntity(brandConfig))
.map(brandConfigConverter::toDomain);
}
}
@@ -0,0 +1,33 @@
-- ============================================
-- V25: 创建品牌配置表
-- ============================================
-- 创建 brand_config 表(租户独立品牌配置)
CREATE TABLE IF NOT EXISTS brand_config (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL,
logo_url VARCHAR(512),
background_image_url VARCHAR(512),
primary_color VARCHAR(20) DEFAULT '#00E676',
primary_color_rgb VARCHAR(30),
secondary_color VARCHAR(20) DEFAULT '#1A1A1A',
secondary_color_rgb VARCHAR(30),
font_family VARCHAR(100) DEFAULT 'default',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 创建唯一索引:每个租户只能有一条品牌配置
CREATE UNIQUE INDEX IF NOT EXISTS idx_brand_config_tenant_id ON brand_config(tenant_id) WHERE deleted_at IS NULL;
-- 添加注释
COMMENT ON TABLE brand_config IS '品牌配置表';
COMMENT ON COLUMN brand_config.tenant_id IS '租户ID';
COMMENT ON COLUMN brand_config.logo_url IS 'Logo图片URL';
COMMENT ON COLUMN brand_config.background_image_url IS '背景图URL';
COMMENT ON COLUMN brand_config.primary_color IS '品牌主色调(HEX格式)';
COMMENT ON COLUMN brand_config.primary_color_rgb IS '品牌主色调(RGB格式)';
COMMENT ON COLUMN brand_config.secondary_color IS '品牌辅助色(HEX格式)';
COMMENT ON COLUMN brand_config.secondary_color_rgb IS '品牌辅助色(RGB格式)';
COMMENT ON COLUMN brand_config.font_family IS '字体';
@@ -0,0 +1,27 @@
-- ============================================
-- V26: 新增品牌定制菜单及按钮权限
-- ============================================
-- 品牌定制菜单(顶级菜单,parent_id=0,排序在轮播图之后)
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at)
VALUES (29, '品牌定制', 0, 9, 'C', 'brand:config:list', 'brand/index', 1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- 品牌定制按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(291, '品牌配置查询', 29, 1, 'F', 'brand:config:query', NULL, 1, NOW(), NOW()),
(292, '品牌配色修改', 29, 2, 'F', 'brand:config:edit', NULL, 1, NOW(), NOW()),
(293, 'Logo上传', 29, 3, 'F', 'brand:logo:upload', NULL, 1, NOW(), NOW()),
(294, '背景图上传', 29, 4, 'F', 'brand:bg:upload', NULL, 1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- 为管理员角色(role_id=1)分配品牌定制权限
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT 1, id FROM sys_permission
WHERE permission_code IN ('brand:config:list', 'brand:config:query', 'brand:config:edit', 'brand:logo:upload', 'brand:bg:upload')
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission WHERE role_id = 1 AND permission_id = sys_permission.id
);
-- 重置菜单序列
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
@@ -0,0 +1,12 @@
-- =====================================================
-- V27: 为 brand_config 表添加品牌名和口号字段
-- 作者: 张翔
-- 日期: 2026-07-23
-- =====================================================
ALTER TABLE brand_config
ADD COLUMN IF NOT EXISTS brand_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS slogan VARCHAR(200);
COMMENT ON COLUMN brand_config.brand_name IS '品牌名称';
COMMENT ON COLUMN brand_config.slogan IS '品牌口号';
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.file.core.domain.SysFile;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Component;
@@ -21,9 +22,12 @@ import java.nio.file.Paths;
public class SysFileHandler {
private final ISysFileService fileService;
private final String uploadDir;
public SysFileHandler(ISysFileService fileService) {
public SysFileHandler(ISysFileService fileService,
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir) {
this.fileService = fileService;
this.uploadDir = uploadDir;
}
@Operation(summary = "获取所有文件", description = "获取系统中所有文件列表")
@@ -88,8 +92,13 @@ public class SysFileHandler {
@Operation(summary = "根据文件名下载", description = "根据文件名下载文件")
public Mono<ServerResponse> downloadFileByName(ServerRequest request) {
String fileName = request.pathVariable("fileName");
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
if (fileName != null && fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
}
final String finalFileName = fileName;
return fileService.getAllFiles()
.filter(file -> file.getFileName().equals(fileName))
.filter(file -> file.getFileName().equals(finalFileName))
.next()
.flatMap(file -> {
try {
@@ -127,8 +136,15 @@ public class SysFileHandler {
@Operation(summary = "根据文件名预览", description = "根据文件名预览文件")
public Mono<ServerResponse> previewFileByName(ServerRequest request) {
String fileName = request.pathVariable("fileName");
// 保存原始路径,用于磁盘文件回退(如 brand/logo/xxx.png
final String originalPath = fileName;
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
if (fileName != null && fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
}
final String finalFileName = fileName;
return fileService.getAllFiles()
.filter(file -> file.getFileName().equals(fileName))
.filter(file -> file.getFileName().equals(finalFileName))
.next()
.flatMap(file -> {
try {
@@ -141,7 +157,28 @@ public class SysFileHandler {
return ServerResponse.notFound().build();
}
})
.switchIfEmpty(ServerResponse.notFound().build());
.switchIfEmpty(Mono.defer(() -> {
// 回退:从上传目录直接读取文件(用于品牌图片等未注册 sys_file 的文件)
try {
Path diskPath = Paths.get(uploadDir, originalPath);
if (Files.exists(diskPath) && !Files.isDirectory(diskPath)) {
byte[] fileContent = Files.readAllBytes(diskPath);
String contentType = "image/" + getFileExtension(originalPath);
return ServerResponse.ok()
.header("Content-Type", contentType)
.bodyValue(fileContent);
}
} catch (Exception ignored) {
}
return ServerResponse.notFound().build();
}));
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) return "jpeg";
String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
if ("jpg".equals(ext)) return "jpeg";
return ext;
}
@Operation(summary = "删除文件", description = "删除指定文件")
@@ -0,0 +1,57 @@
package cn.novalon.gym.manage.file.core.domain;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
class SysFileTest {
@Test
void shouldCreateSysFileWithCorrectProperties() {
SysFile file = new SysFile();
file.setId(1L);
file.setFileName("test.png");
file.setFileType("image/png");
file.setFileSize(1024L);
file.setFilePath("/uploads/test.png");
file.setCreatedAt(LocalDateTime.of(2025, 1, 1, 10, 0, 0));
assertThat(file.getId()).isEqualTo(1L);
assertThat(file.getFileName()).isEqualTo("test.png");
assertThat(file.getFileType()).isEqualTo("image/png");
assertThat(file.getFileSize()).isEqualTo(1024L);
assertThat(file.getFilePath()).isEqualTo("/uploads/test.png");
assertThat(file.getCreatedAt()).isNotNull();
}
@Test
void shouldHandleNullValues() {
SysFile file = new SysFile();
assertThat(file.getId()).isNull();
assertThat(file.getFileName()).isNull();
assertThat(file.getFileType()).isNull();
assertThat(file.getFileSize()).isNull();
assertThat(file.getFilePath()).isNull();
}
@Test
void shouldSetAndGetAllProperties() {
SysFile file = new SysFile();
LocalDateTime now = LocalDateTime.now();
file.setId(100L);
file.setFileName("document.pdf");
file.setFileType("application/pdf");
file.setFileSize(20480L);
file.setFilePath("/files/2025/document.pdf");
file.setCreatedAt(now);
file.setDeletedAt(now);
assertThat(file.getId()).isEqualTo(100L);
assertThat(file.getFileSize()).isEqualTo(20480L);
assertThat(file.getDeletedAt()).isEqualTo(now);
}
}
@@ -29,7 +29,7 @@ class SysFileHandlerTest {
@BeforeEach
void setUp() {
fileHandler = new SysFileHandler(fileService);
fileHandler = new SysFileHandler(fileService, "/tmp/uploads");
testFile = new SysFile();
testFile.setId(1L);
testFile.setFileName("test.txt");
@@ -44,11 +44,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
String username = jwtUtil.getUsernameFromToken(token);
Long userId = jwtUtil.getUserIdFromToken(token);
String tenantId = jwtUtil.getTenantIdFromToken(token);
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-Member-Id", String.valueOf(userId))
.header("X-Username", username)
.header("X-Tenant-Id", tenantId)
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
@@ -74,6 +74,12 @@ public class JwtUtil {
return claims.get("userId", Long.class);
}
public String getTenantIdFromToken(String token) {
Claims claims = parseToken(token);
String tenantId = claims.get("tenantId", String.class);
return tenantId != null ? tenantId : "default";
}
public boolean validateToken(String token) {
try {
parseToken(token);
@@ -0,0 +1,186 @@
package cn.novalon.gym.manage.notify.core.service.impl;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.notify.core.repository.IBannerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BannerServiceImplTest {
@Mock
private IBannerRepository bannerRepository;
private BannerServiceImpl bannerService;
@BeforeEach
void setUp() {
bannerService = new BannerServiceImpl(bannerRepository);
}
// ==================== getAllBanners ====================
@Test
void getAllBanners_shouldReturnNonDeletedBanners() {
Banner banner = createTestBanner(1L, "轮播图1");
when(bannerRepository.findByDeletedAtIsNull()).thenReturn(Flux.just(banner));
Flux<Banner> result = bannerService.getAllBanners();
StepVerifier.create(result)
.expectNextCount(1)
.verifyComplete();
verify(bannerRepository).findByDeletedAtIsNull();
}
@Test
void getAllBanners_shouldReturnEmptyWhenNone() {
when(bannerRepository.findByDeletedAtIsNull()).thenReturn(Flux.empty());
Flux<Banner> result = bannerService.getAllBanners();
StepVerifier.create(result)
.verifyComplete();
}
// ==================== getActiveBanners ====================
@Test
void getActiveBanners_shouldReturnActiveBanners() {
when(bannerRepository.findActiveBanners()).thenReturn(Flux.just(createTestBanner(1L, "活跃")));
Flux<Banner> result = bannerService.getActiveBanners();
StepVerifier.create(result)
.expectNextCount(1)
.verifyComplete();
verify(bannerRepository).findActiveBanners();
}
// ==================== getBannerById ====================
@Test
void getBannerById_shouldReturnBannerWhenFound() {
when(bannerRepository.findById(1L)).thenReturn(Mono.just(createTestBanner(1L, "轮播图1")));
Mono<Banner> result = bannerService.getBannerById(1L);
StepVerifier.create(result)
.expectNextCount(1)
.verifyComplete();
}
@Test
void getBannerById_shouldReturnEmptyWhenNotFound() {
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
Mono<Banner> result = bannerService.getBannerById(999L);
StepVerifier.create(result)
.verifyComplete();
}
// ==================== createBanner ====================
@Test
void createBanner_shouldSaveAndReturn() {
Banner banner = createTestBanner(null, "新轮播图");
when(bannerRepository.save(any(Banner.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<Banner> result = bannerService.createBanner(banner);
StepVerifier.create(result)
.expectNextMatches(b -> b.getTitle().equals("新轮播图") && b.getCreatedAt() != null)
.verifyComplete();
verify(bannerRepository).save(banner);
}
// ==================== updateBanner ====================
@Test
void updateBanner_shouldUpdateAndReturn() {
Banner existing = createTestBanner(1L, "旧标题");
Banner update = createTestBanner(1L, "新标题");
when(bannerRepository.findById(1L)).thenReturn(Mono.just(existing));
when(bannerRepository.save(any(Banner.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<Banner> result = bannerService.updateBanner(1L, update);
StepVerifier.create(result)
.expectNextMatches(b -> b.getTitle().equals("新标题") && b.getUpdatedAt() != null)
.verifyComplete();
verify(bannerRepository).save(existing);
}
@Test
void updateBanner_shouldReturnEmptyWhenNotFound() {
Banner update = createTestBanner(999L, "新标题");
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
Mono<Banner> result = bannerService.updateBanner(999L, update);
StepVerifier.create(result)
.verifyComplete();
verify(bannerRepository, never()).save(any());
}
// ==================== deleteBanner ====================
@Test
void deleteBanner_shouldSoftDelete() {
Banner existing = createTestBanner(1L, "轮播图1");
when(bannerRepository.findById(1L)).thenReturn(Mono.just(existing));
when(bannerRepository.save(any(Banner.class))).thenReturn(Mono.just(existing));
Mono<Void> result = bannerService.deleteBanner(1L);
StepVerifier.create(result)
.verifyComplete();
verify(bannerRepository).save(existing);
assertThat(existing.getDeletedAt()).isNotNull();
}
@Test
void deleteBanner_shouldCompleteEmptyWhenNotFound() {
when(bannerRepository.findById(999L)).thenReturn(Mono.empty());
Mono<Void> result = bannerService.deleteBanner(999L);
StepVerifier.create(result)
.verifyComplete();
}
// ==================== helper ====================
private Banner createTestBanner(Long id, String title) {
Banner banner = new Banner();
banner.setId(id);
banner.setImageUrl("https://example.com/banner.jpg");
banner.setTitle(title);
banner.setSubtitle("副标题");
banner.setSortOrder(1);
banner.setIsActive("1");
banner.setCreatedAt(LocalDateTime.now());
return banner;
}
}
@@ -0,0 +1,170 @@
package cn.novalon.gym.manage.notify.core.service.impl;
import cn.novalon.gym.manage.notify.core.domain.SysNotice;
import cn.novalon.gym.manage.notify.core.repository.ISysNoticeRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SysNoticeServiceImplTest {
@Mock
private ISysNoticeRepository noticeRepository;
private SysNoticeServiceImpl noticeService;
@BeforeEach
void setUp() {
noticeService = new SysNoticeServiceImpl(noticeRepository);
}
// ==================== getAllNotices ====================
@Test
void getAllNotices_shouldReturnNonDeletedNotices() {
SysNotice notice = createTestNotice(1L, "公告1");
when(noticeRepository.findByDeletedAtIsNull()).thenReturn(Flux.just(notice));
Flux<SysNotice> result = noticeService.getAllNotices();
StepVerifier.create(result)
.expectNextMatches(n -> n.getId().equals(1L) && n.getNoticeTitle().equals("公告1"))
.verifyComplete();
verify(noticeRepository).findByDeletedAtIsNull();
}
@Test
void getAllNotices_shouldReturnEmptyWhenNone() {
when(noticeRepository.findByDeletedAtIsNull()).thenReturn(Flux.empty());
Flux<SysNotice> result = noticeService.getAllNotices();
StepVerifier.create(result)
.verifyComplete();
}
// ==================== getNoticeById ====================
@Test
void getNoticeById_shouldReturnNoticeWhenFound() {
SysNotice notice = createTestNotice(1L, "公告1");
when(noticeRepository.findById(1L)).thenReturn(Mono.just(notice));
Mono<SysNotice> result = noticeService.getNoticeById(1L);
StepVerifier.create(result)
.expectNextMatches(n -> n.getId().equals(1L))
.verifyComplete();
}
@Test
void getNoticeById_shouldReturnEmptyWhenNotFound() {
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
Mono<SysNotice> result = noticeService.getNoticeById(999L);
StepVerifier.create(result)
.verifyComplete();
}
// ==================== createNotice ====================
@Test
void createNotice_shouldSaveAndReturnNotice() {
SysNotice notice = createTestNotice(null, "新公告");
when(noticeRepository.save(any(SysNotice.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<SysNotice> result = noticeService.createNotice(notice);
StepVerifier.create(result)
.expectNextMatches(n -> n.getNoticeTitle().equals("新公告"))
.verifyComplete();
verify(noticeRepository).save(notice);
}
// ==================== updateNotice ====================
@Test
void updateNotice_shouldUpdateAndReturn() {
SysNotice existing = createTestNotice(1L, "旧标题");
SysNotice update = createTestNotice(1L, "新标题");
when(noticeRepository.findById(1L)).thenReturn(Mono.just(existing));
when(noticeRepository.save(any(SysNotice.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<SysNotice> result = noticeService.updateNotice(1L, update);
StepVerifier.create(result)
.expectNextMatches(n -> n.getNoticeTitle().equals("新标题"))
.verifyComplete();
verify(noticeRepository).save(existing);
}
@Test
void updateNotice_shouldReturnEmptyWhenNotFound() {
SysNotice update = createTestNotice(999L, "新标题");
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
Mono<SysNotice> result = noticeService.updateNotice(999L, update);
StepVerifier.create(result)
.verifyComplete();
verify(noticeRepository, never()).save(any());
}
// ==================== deleteNotice ====================
@Test
void deleteNotice_shouldSoftDelete() {
SysNotice existing = createTestNotice(1L, "公告1");
when(noticeRepository.findById(1L)).thenReturn(Mono.just(existing));
when(noticeRepository.save(any(SysNotice.class))).thenReturn(Mono.just(existing));
Mono<Void> result = noticeService.deleteNotice(1L);
StepVerifier.create(result)
.verifyComplete();
verify(noticeRepository).save(existing);
assertThat(existing.getDeletedAt()).isNotNull();
}
@Test
void deleteNotice_shouldCompleteEmptyWhenNotFound() {
when(noticeRepository.findById(999L)).thenReturn(Mono.empty());
Mono<Void> result = noticeService.deleteNotice(999L);
StepVerifier.create(result)
.verifyComplete();
verify(noticeRepository, never()).save(any());
}
// ==================== helper ====================
private SysNotice createTestNotice(Long id, String title) {
SysNotice notice = new SysNotice();
notice.setId(id);
notice.setNoticeTitle(title);
notice.setNoticeContent("公告内容");
notice.setNoticeType("1");
notice.setStatus("1");
return notice;
}
}
@@ -0,0 +1,195 @@
package cn.novalon.gym.manage.notify.core.service.impl;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import cn.novalon.gym.manage.notify.core.repository.ISysUserMessageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SysUserMessageServiceImplTest {
@Mock
private ISysUserMessageRepository messageRepository;
private SysUserMessageServiceImpl messageService;
@BeforeEach
void setUp() {
messageService = new SysUserMessageServiceImpl(messageRepository);
}
// ==================== getMessagesByUser ====================
@Test
void getMessagesByUser_shouldReturnMessages() {
List<SysUserMessage> messages = List.of(createTestMessage(1L, 1L, "消息1"), createTestMessage(2L, 1L, "消息2"));
when(messageRepository.findByUserIdOrderByCreateTimeDesc(1L)).thenReturn(Flux.fromIterable(messages));
Flux<SysUserMessage> result = messageService.getMessagesByUser(1L);
StepVerifier.create(result)
.expectNextCount(2)
.verifyComplete();
}
@Test
void getMessagesByUser_shouldReturnEmptyWhenNone() {
when(messageRepository.findByUserIdOrderByCreateTimeDesc(999L)).thenReturn(Flux.empty());
Flux<SysUserMessage> result = messageService.getMessagesByUser(999L);
StepVerifier.create(result)
.verifyComplete();
}
// ==================== getUnreadCount ====================
@Test
void getUnreadCount_shouldReturnCount() {
when(messageRepository.countByUserIdAndIsRead(1L, "0")).thenReturn(Mono.just(5L));
Mono<Long> result = messageService.getUnreadCount(1L);
StepVerifier.create(result)
.expectNext(5L)
.verifyComplete();
}
@Test
void getUnreadCount_shouldReturnZero() {
when(messageRepository.countByUserIdAndIsRead(1L, "0")).thenReturn(Mono.just(0L));
Mono<Long> result = messageService.getUnreadCount(1L);
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
}
// ==================== getUnreadMessages ====================
@Test
void getUnreadMessages_shouldReturnUnreadOnly() {
when(messageRepository.findByUserIdAndIsReadOrderByCreateTimeDesc(1L, "0"))
.thenReturn(Flux.just(createTestMessage(1L, 1L, "未读消息")));
Flux<SysUserMessage> result = messageService.getUnreadMessages(1L);
StepVerifier.create(result)
.expectNextCount(1)
.verifyComplete();
}
// ==================== createMessage ====================
@Test
void createMessage_shouldSaveAndReturn() {
SysUserMessage message = createTestMessage(null, 1L, "新消息");
when(messageRepository.save(any(SysUserMessage.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<SysUserMessage> result = messageService.createMessage(message);
StepVerifier.create(result)
.expectNextMatches(m -> m.getTitle().equals("新消息"))
.verifyComplete();
verify(messageRepository).save(message);
}
// ==================== markAsRead ====================
@Test
void markAsRead_shouldUpdateAndReturnMessage() {
SysUserMessage existing = createTestMessage(1L, 1L, "消息");
when(messageRepository.findById(1L)).thenReturn(Mono.just(existing));
when(messageRepository.save(any(SysUserMessage.class))).thenReturn(Mono.just(existing));
Mono<SysUserMessage> result = messageService.markAsRead(1L);
StepVerifier.create(result)
.expectNextMatches(m -> m.getIsRead().equals("1"))
.verifyComplete();
verify(messageRepository).save(existing);
assertThat(existing.getIsRead()).isEqualTo("1");
}
@Test
void markAsRead_shouldReturnEmptyWhenNotFound() {
when(messageRepository.findById(999L)).thenReturn(Mono.empty());
Mono<SysUserMessage> result = messageService.markAsRead(999L);
StepVerifier.create(result)
.verifyComplete();
verify(messageRepository, never()).save(any());
}
// ==================== markAllAsRead ====================
@Test
void markAllAsRead_shouldReturnCount() {
when(messageRepository.markAllAsReadByUserId(1L)).thenReturn(Mono.just(2L));
Mono<Long> result = messageService.markAllAsRead(1L);
StepVerifier.create(result)
.expectNext(2L)
.verifyComplete();
verify(messageRepository).markAllAsReadByUserId(1L);
}
@Test
void markAllAsRead_shouldReturnZeroWhenNoUnread() {
when(messageRepository.markAllAsReadByUserId(1L)).thenReturn(Mono.just(0L));
Mono<Long> result = messageService.markAllAsRead(1L);
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
}
// ==================== deleteMessage ====================
@Test
void deleteMessage_shouldDelete() {
when(messageRepository.deleteById(1L)).thenReturn(Mono.empty());
Mono<Void> result = messageService.deleteMessage(1L);
StepVerifier.create(result)
.verifyComplete();
verify(messageRepository).deleteById(1L);
}
// ==================== helper ====================
private SysUserMessage createTestMessage(Long id, Long userId, String title) {
SysUserMessage message = new SysUserMessage();
message.setId(id);
message.setUserId(userId);
message.setTitle(title);
message.setContent("测试内容");
message.setIsRead("0");
message.setCreateTime(LocalDateTime.now());
return message;
}
}
@@ -0,0 +1,219 @@
package cn.novalon.gym.manage.notify.handler;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.notify.core.service.IBannerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BannerHandlerTest {
@Mock
private IBannerService bannerService;
private BannerHandler bannerHandler;
@BeforeEach
void setUp() {
bannerHandler = new BannerHandler(bannerService);
}
// ==================== getAllBanners ====================
@Test
void getAllBanners_shouldReturnOkWithBanners() {
List<Banner> banners = List.of(createTestBanner(1L, "轮播图1"), createTestBanner(2L, "轮播图2"));
when(bannerService.getAllBanners()).thenReturn(Flux.fromIterable(banners));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = bannerHandler.getAllBanners(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void getAllBanners_shouldReturnOkWhenEmpty() {
when(bannerService.getAllBanners()).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = bannerHandler.getAllBanners(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== getActiveBanners ====================
@Test
void getActiveBanners_shouldReturnOkWithActiveBanners() {
when(bannerService.getActiveBanners()).thenReturn(Flux.just(createTestBanner(1L, "活跃轮播图")));
MockServerRequest request = MockServerRequest.builder().build();
Mono<ServerResponse> result = bannerHandler.getActiveBanners(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== getBannerById ====================
@Test
void getBannerById_shouldReturnOkWhenFound() {
when(bannerService.getBannerById(1L)).thenReturn(Mono.just(createTestBanner(1L, "轮播图1")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = bannerHandler.getBannerById(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void getBannerById_shouldReturnNotFound() {
when(bannerService.getBannerById(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = bannerHandler.getBannerById(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
.verifyComplete();
}
// ==================== createBanner ====================
@Test
void createBanner_shouldReturnOkWhenCreated() {
Map<String, Object> body = Map.of(
"imageUrl", "https://example.com/banner.jpg",
"title", "新轮播图",
"subtitle", "副标题",
"sortOrder", 1,
"isActive", true
);
when(bannerService.createBanner(any(Banner.class))).thenReturn(Mono.just(createTestBanner(1L, "新轮播图")));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(body));
Mono<ServerResponse> result = bannerHandler.createBanner(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== updateBanner ====================
@Test
void updateBanner_shouldReturnOkWhenUpdated() {
Map<String, Object> body = Map.of("title", "更新标题");
when(bannerService.updateBanner(eq(1L), any(Banner.class))).thenReturn(Mono.just(createTestBanner(1L, "更新标题")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.body(Mono.just(body));
Mono<ServerResponse> result = bannerHandler.updateBanner(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void updateBanner_shouldReturnNotFound() {
Map<String, Object> body = Map.of("title", "更新标题");
when(bannerService.updateBanner(eq(999L), any(Banner.class))).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.body(Mono.just(body));
Mono<ServerResponse> result = bannerHandler.updateBanner(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
.verifyComplete();
}
// ==================== deleteBanner ====================
@Test
void deleteBanner_shouldReturnNoContent() {
Banner banner = createTestBanner(1L, "轮播图1");
banner.setDeletedAt(null);
when(bannerService.getBannerById(1L)).thenReturn(Mono.just(banner));
when(bannerService.deleteBanner(1L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = bannerHandler.deleteBanner(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT))
.verifyComplete();
}
@Test
void deleteBanner_shouldReturnNotFound() {
when(bannerService.getBannerById(999L)).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = bannerHandler.deleteBanner(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
.verifyComplete();
}
// ==================== helper ====================
private Banner createTestBanner(Long id, String title) {
Banner banner = new Banner();
banner.setId(id);
banner.setImageUrl("https://example.com/banner-" + id + ".jpg");
banner.setTitle(title);
banner.setSubtitle("副标题");
banner.setSortOrder(id.intValue());
banner.setIsActive("1");
banner.setCreatedAt(LocalDateTime.now());
return banner;
}
}
@@ -0,0 +1,228 @@
package cn.novalon.gym.manage.notify.handler;
import cn.novalon.gym.manage.notify.core.domain.SysUserMessage;
import cn.novalon.gym.manage.notify.core.service.ISysUserMessageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SysUserMessageHandlerTest {
@Mock
private ISysUserMessageService messageService;
private SysUserMessageHandler handler;
@BeforeEach
void setUp() {
handler = new SysUserMessageHandler(messageService);
}
// ==================== getMessagesByUser ====================
@Test
void getMessagesByUser_shouldReturnOkWithMessages() {
List<SysUserMessage> messages = List.of(createTestMessage(1L, "消息1"), createTestMessage(2L, "消息2"));
when(messageService.getMessagesByUser(anyLong())).thenReturn(Flux.fromIterable(messages));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "1")
.build();
Mono<ServerResponse> result = handler.getMessagesByUser(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void getMessagesByUser_shouldReturnEmptyListWhenNone() {
when(messageService.getMessagesByUser(anyLong())).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "999")
.build();
Mono<ServerResponse> result = handler.getMessagesByUser(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== getUnreadCount ====================
@Test
void getUnreadCount_shouldReturnOkWithCount() {
when(messageService.getUnreadCount(anyLong())).thenReturn(Mono.just(5L));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "1")
.build();
Mono<ServerResponse> result = handler.getUnreadCount(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void getUnreadCount_shouldReturnZero() {
when(messageService.getUnreadCount(anyLong())).thenReturn(Mono.just(0L));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "1")
.build();
Mono<ServerResponse> result = handler.getUnreadCount(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== getUnreadList ====================
@Test
void getUnreadList_shouldReturnOk() {
when(messageService.getUnreadMessages(anyLong())).thenReturn(Flux.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "1")
.build();
Mono<ServerResponse> result = handler.getUnreadList(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== createMessage ====================
@Test
void createMessage_shouldReturnCreated() {
SysUserMessage message = createTestMessage(1L, "新消息");
when(messageService.createMessage(any(SysUserMessage.class))).thenReturn(Mono.just(message));
MockServerRequest request = MockServerRequest.builder()
.body(Mono.just(message));
Mono<ServerResponse> result = handler.createMessage(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== markAsRead ====================
@Test
void markAsRead_shouldReturnOk() {
SysUserMessage message = createTestMessage(1L, "消息");
when(messageService.markAsRead(anyLong())).thenReturn(Mono.just(message));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.markAsRead(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void markAsRead_shouldReturnNotFound() {
when(messageService.markAsRead(anyLong())).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.markAsRead(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND))
.verifyComplete();
}
// ==================== markAllAsRead ====================
@Test
void markAllAsRead_shouldReturnOk() {
when(messageService.markAllAsRead(anyLong())).thenReturn(Mono.just(3L));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("userId", "1")
.build();
Mono<ServerResponse> result = handler.markAllAsRead(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== deleteMessage ====================
@Test
void deleteMessage_shouldReturnOk() {
when(messageService.deleteMessage(anyLong())).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "1")
.build();
Mono<ServerResponse> result = handler.deleteMessage(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void deleteMessage_shouldReturnNotFound() {
when(messageService.deleteMessage(anyLong())).thenReturn(Mono.empty());
MockServerRequest request = MockServerRequest.builder()
.pathVariable("id", "999")
.build();
Mono<ServerResponse> result = handler.deleteMessage(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== helper ====================
private SysUserMessage createTestMessage(Long id, String title) {
SysUserMessage message = new SysUserMessage();
message.setId(id);
message.setUserId(1L);
message.setTitle(title);
message.setContent("测试内容-" + id);
message.setIsRead("0");
return message;
}
}
@@ -67,7 +67,8 @@ public class SecurityConfig {
.pathMatchers("/api/payment/notify").permitAll()
.pathMatchers("/api/payment/refund").permitAll()
.pathMatchers("/api/files/**").permitAll()
.pathMatchers("/api/coach/**").permitAll();
.pathMatchers("/api/coach/**").permitAll()
.pathMatchers("/api/brand").permitAll();
if (isDevOrTest) {
spec.pathMatchers("/swagger-ui.html").permitAll()
@@ -31,10 +31,16 @@ public class JwtTokenProvider {
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
/**
* 默认租户ID,后续多租户化时替换为从用户上下文获取
*/
public static final String DEFAULT_TENANT_ID = "default";
public String generateToken(String username, Long userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("tenantId", DEFAULT_TENANT_ID);
return Jwts.builder()
.setClaims(claims)
@@ -50,6 +56,7 @@ public class JwtTokenProvider {
claims.put("userId", userId);
claims.put("username", username);
claims.put("roles", roles);
claims.put("tenantId", DEFAULT_TENANT_ID);
return Jwts.builder()
.setClaims(claims)
@@ -76,6 +83,11 @@ public class JwtTokenProvider {
return getClaimsFromToken(token).get("userId", Long.class);
}
public String getTenantIdFromToken(String token) {
String tenantId = getClaimsFromToken(token).get("tenantId", String.class);
return tenantId != null ? tenantId : DEFAULT_TENANT_ID;
}
@SuppressWarnings("unchecked")
public java.util.List<String> getRolesFromToken(String token) {
Object roles = getClaimsFromToken(token).get("roles");
@@ -29,4 +29,32 @@ public class AuthUtil {
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
return jwtTokenProvider.getUserIdFromToken(token);
}
/**
* 从 JWT Token 中提取当前用户的租户ID
*/
public String getTenantIdOrThrow(ServerRequest request) {
String tenantId = getTenantId(request);
if (tenantId == null || tenantId.isBlank()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "无法获取租户ID");
}
return tenantId;
}
/**
* 从 JWT Token 或 Gateway Header 中提取租户ID(不抛异常)
*/
public String getTenantId(ServerRequest request) {
// 优先从 Gateway 转发的 Header 获取
String headerTenantId = request.headers().firstHeader("X-Tenant-Id");
if (headerTenantId != null && !headerTenantId.isBlank()) {
return headerTenantId;
}
// 回退到 JWT Token 中提取
String token = extractToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
return jwtTokenProvider.getTenantIdFromToken(token);
}
return JwtTokenProvider.DEFAULT_TENANT_ID;
}
}
+7
View File
@@ -49,6 +49,7 @@
<module>gym-auth</module>
<module>gym-payment</module>
<module>gym-coach</module>
<module>gym-brand</module>
</modules>
<dependencyManagement>
@@ -219,6 +220,12 @@
<artifactId>commons-compress</artifactId>
<version>1.21</version>
</dependency>
<!-- Aliyun OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
</dependencies>
</dependencyManagement>
+4
View File
@@ -1,10 +1,14 @@
<script>
const brandStore = require('./store/brand')
export default {
onLaunch: function() {
console.log('Coach App Launch')
brandStore.fetchConfig()
},
onShow: function() {
console.log('Coach App Show')
brandStore.fetchConfig()
},
onHide: function() {
console.log('Coach App Hide')
+11
View File
@@ -0,0 +1,11 @@
const http = require('../utils/request')
module.exports = {
/**
* 获取品牌配置(需 JWT 登录态,自动按 tenantId 返回对应租户配置)
* @returns {Promise<Object>} 品牌配置对象
*/
getBrandConfig() {
return http.get('/brand')
}
}
@@ -125,6 +125,52 @@ async function getCourseBookings(courseId, token) {
return res;
}
// ── 品牌定制 API ──
/** 获取品牌配置 */
async function getBrandConfig(token) {
console.log('[API] 获取品牌配置...');
const res = await makeRequest('GET', `${API_BASE}/brand`, null, token);
console.log(`[API] 品牌配置: status=${res.status}`);
return res;
}
// ── 违规记录 API ──
/** 获取所有教练违规统计 */
async function getViolationCounts(token) {
console.log('[API] 获取违规统计...');
const res = await makeRequest('GET', `${API_BASE}/coach/violation-counts`, null, token);
console.log(`[API] 违规统计: status=${res.status}`);
return res;
}
/** 获取指定教练的违规记录 */
async function getCoachViolations(coachId, token) {
console.log(`[API] 获取教练违规: coachId=${coachId}`);
const res = await makeRequest('GET', `${API_BASE}/coach/${coachId}/violations`, null, token);
console.log(`[API] 违规记录: status=${res.status}`);
return res;
}
// ── 通知/消息 API ──
/** 获取活跃Banner列表 */
async function getActiveBanners(token) {
console.log('[API] 获取活跃Banners...');
const res = await makeRequest('GET', `${API_BASE}/banners/active`, null, token);
console.log(`[API] Banners: status=${res.status}`);
return res;
}
/** 获取系统通知列表 */
async function getSystemNotices(token) {
console.log('[API] 获取系统通知...');
const res = await makeRequest('GET', `${API_BASE}/notices`, null, token);
console.log(`[API] 通知列表: status=${res.status}`);
return res;
}
module.exports = {
makeRequest,
coachLogin,
@@ -133,6 +179,11 @@ module.exports = {
startCourse,
endCourse,
getCourseBookings,
getBrandConfig,
getViolationCounts,
getCoachViolations,
getActiveBanners,
getSystemNotices,
isSuccess,
BASE_URL,
API_BASE
@@ -0,0 +1,170 @@
/**
* P3-API - 教练端品牌定制 & 通知消息 & 违规记录 API 测试
* 流程:教练登录 → 获取品牌配置 → 获取Banner/通知 → 获取违规记录
*
* 使用 HTTP APIHMAC-SHA256 签名 + JWT Token
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
*/
const api = require('../helpers/api-helper');
const COACH_USERNAME = 'coach_zhang';
const COACH_PASSWORD = 'Test@123';
let authToken = null;
let coachId = null;
describe('P3-API - 教练端品牌定制 & 通知 & 违规记录', () => {
// ════════════════════════════════════════════════════
// 步骤0:教练登录
// ════════════════════════════════════════════════════
beforeAll(async () => {
console.log('════════════════════════════════════════════');
console.log('P3-API - 教练端品牌定制 & 通知 & 违规记录');
console.log('════════════════════════════════════════════');
const result = await api.coachLogin(COACH_USERNAME, COACH_PASSWORD);
expect(result).not.toBeNull();
expect(result.token).toBeDefined();
authToken = result.token;
coachId = result.userId;
console.log(`[P3-API] 教练登录成功: userId=${coachId}`);
}, 30000);
// ════════════════════════════════════════════════════
// 步骤1:品牌定制配置
// ════════════════════════════════════════════════════
describe('品牌定制配置', () => {
test('TC-COACH-BRAND-001: 获取品牌配置', async () => {
const res = await api.getBrandConfig(authToken);
console.log(`[TC-COACH-BRAND-001] status=${res.status}`);
if (api.isSuccess(res)) {
const brand = res.data?.data || res.data || {};
console.log(` 品牌名称: ${brand.brandName || '(未设置)'}`);
console.log(` 品牌口号: ${brand.slogan || '(未设置)'}`);
console.log(` 主色调: ${brand.primaryColor || '(默认)'}`);
console.log(` 辅助色: ${brand.secondaryColor || '(默认)'}`);
expect(brand).toBeDefined();
}
});
test('TC-COACH-BRAND-002: 品牌颜色为合法HEX值', async () => {
const res = await api.getBrandConfig(authToken);
if (!api.isSuccess(res)) {
console.warn('[TC-COACH-BRAND-002] 获取失败,跳过');
return;
}
const brand = res.data?.data || res.data || {};
if (brand.primaryColor) {
expect(/^#[0-9A-Fa-f]{6,8}$/.test(brand.primaryColor)).toBe(true);
console.log(` 主色调 HEX 合法: ${brand.primaryColor}`);
}
if (brand.secondaryColor) {
expect(/^#[0-9A-Fa-f]{6,8}$/.test(brand.secondaryColor)).toBe(true);
console.log(` 辅助色 HEX 合法: ${brand.secondaryColor}`);
}
});
});
// ════════════════════════════════════════════════════
// 步骤2:违规记录
// ════════════════════════════════════════════════════
describe('违规记录', () => {
test('TC-VIOLATION-001: 获取所有教练违规统计', async () => {
const res = await api.getViolationCounts(authToken);
console.log(`[TC-VIOLATION-001] status=${res.status}`);
if (api.isSuccess(res)) {
const counts = res.data?.data || res.data || [];
console.log(` 违规统计条目数: ${Array.isArray(counts) ? counts.length : 'N/A'}`);
if (Array.isArray(counts) && counts.length > 0) {
counts.slice(0, 3).forEach(c => {
console.log(` - coachName=${c.coachName || c.name}, violations=${c.violationCount || c.count}`);
});
}
}
});
test('TC-VIOLATION-002: 获取当前教练违规记录', async () => {
const res = await api.getCoachViolations(coachId, authToken);
console.log(`[TC-VIOLATION-002] status=${res.status}`);
if (api.isSuccess(res)) {
const violations = res.data?.data || res.data || [];
const records = Array.isArray(violations) ? violations : (violations.content || violations.records || []);
console.log(` 教练 ${coachId} 违规记录数: ${records.length}`);
if (records.length > 0) {
records.slice(0, 3).forEach(v => {
console.log(` - type=${v.type || v.violationType}, time=${v.time || v.createTime}, desc=${(v.description || '').substring(0, 40)}`);
});
}
}
});
test('TC-VIOLATION-003: 不存在教练ID应返回空', async () => {
const res = await api.getCoachViolations(99999, authToken);
console.log(`[TC-VIOLATION-003] 不存在教练: status=${res.status}`);
if (api.isSuccess(res)) {
const violations = res.data?.data || res.data || [];
const records = Array.isArray(violations) ? violations : [];
expect(records.length).toBe(0);
}
});
});
// ════════════════════════════════════════════════════
// 步骤3Banner & 系统通知
// ════════════════════════════════════════════════════
describe('Banner & 系统通知', () => {
test('TC-COACH-NOTIFY-001: 获取活跃Banner', async () => {
const res = await api.getActiveBanners(authToken);
console.log(`[TC-COACH-NOTIFY-001] status=${res.status}`);
if (api.isSuccess(res)) {
const banners = res.data?.data || res.data || [];
const records = Array.isArray(banners) ? banners : (banners.records || []);
console.log(` 活跃Banner数量: ${records.length}`);
expect(records.length).toBeGreaterThanOrEqual(0);
}
});
test('TC-COACH-NOTIFY-002: 获取系统通知', async () => {
const res = await api.getSystemNotices(authToken);
console.log(`[TC-COACH-NOTIFY-002] status=${res.status}`);
if (api.isSuccess(res)) {
const notices = res.data?.data || res.data || [];
const records = Array.isArray(notices) ? notices : (notices.records || []);
console.log(` 系统通知数量: ${records.length}`);
}
});
});
// ════════════════════════════════════════════════════
// 步骤4:总结
// ════════════════════════════════════════════════════
describe('总结', () => {
test('TC-P3-COACH-SUMMARY: 教练端品牌/通知/违规测试汇总', () => {
console.log('');
console.log('════════════════════════════════════════════');
console.log('P3-API - 教练端品牌 & 通知 & 违规记录测试结束');
console.log('════════════════════════════════════════════');
console.log(' 已测试:');
console.log(' 1. 品牌配置 (名称/颜色)');
console.log(' 2. 所有教练违规统计');
console.log(' 3. 当前教练违规记录');
console.log(' 4. 不存在教练违规边界');
console.log(' 5. 活跃Banner列表');
console.log(' 6. 系统通知列表');
console.log('════════════════════════════════════════════');
expect(coachId).toBeDefined();
expect(authToken).toBeTruthy();
});
});
});
@@ -1,6 +1,6 @@
<template>
<view class="detail-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">课程详情</text>
@@ -11,7 +11,7 @@
<view class="cover-area">
<image v-if="course.coverImage && !loading" class="cover-img" :src="course.coverImage" mode="aspectFill"
@error="course.coverError = true" />
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg">
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg" :style="{ background: coverGradient }">
<text class="cover-text">{{ (course.typeName ? course.typeName.slice(0, 2) : '团课') }}</text>
</view>
<view class="cover-status" :class="courseStatusClass" v-if="!loading">
@@ -47,7 +47,7 @@
</view>
<view class="info-item" v-if="course.storedValueAmount">
<text class="info-label">消耗</text>
<text class="info-value price-text">¥{{ course.storedValueAmount }}</text>
<text class="info-value price-text" :style="{ color: brandConfig.primaryColor }">¥{{ course.storedValueAmount }}</text>
</view>
</view>
@@ -63,6 +63,7 @@
v-if="course.status == 0"
class="action-btn start-btn"
:class="{ disabled: !canStartCourse }"
:style="startBtnStyle"
:loading="actionLoading"
:disabled="actionLoading || !canStartCourse"
@click="handleStartCourse"
@@ -100,6 +101,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
const { resolveCoverUrl } = require('../../utils/request')
export default {
@@ -113,11 +115,18 @@
actionLoading: false,
course: {},
now: Date.now(),
realMemberCount: 0
realMemberCount: 0,
brandConfig: brandStore.config
}
},
computed: {
duration() {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
startBtnStyle() {
if (!this.canStartCourse) return ''
return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
},
coverGradient() { return 'linear-gradient(135deg, ' + this.brandConfig.primaryColor + ', #00BFA5)' },
duration() {
if (!this.course.startTime || !this.course.endTime) return '--'
const start = new Date(this.course.startTime).getTime()
const end = new Date(this.course.endTime).getTime()
@@ -158,6 +167,11 @@
this.courseId = options.id
this.loadDetail()
}
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
if (!store.isLoggedIn) {
@@ -1,6 +1,6 @@
<template>
<view class="course-list-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">我的团课</text>
@@ -12,6 +12,7 @@
:key="idx"
class="tab-item"
:class="{ active: currentTab === tab.value }"
:style="currentTab === tab.value ? tabActiveStyle : ''"
@click="switchTab(tab.value)"
>
<text>{{ tab.label }}</text>
@@ -33,7 +34,7 @@
<view v-for="course in filteredCourses" :key="course.id" class="course-card" :class="{ grayed: !course._isToday }" @click="goToDetail(course.id)">
<view class="card-header">
<text class="course-name">{{ course.courseName }}</text>
<view class="status-tag" :class="course._statusClass">
<view class="status-tag" :class="course._statusClass" :style="course._statusClass === 'normal' ? statusNormalStyle : ''">
<text>{{ course._statusLabel }}</text>
</view>
</view>
@@ -62,6 +63,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -78,10 +80,14 @@
{ label: '待开课', value: 'pending' },
{ label: '进行中', value: 'in_progress' },
{ label: '已结束', value: 'ended' }
]
],
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
tabActiveStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor },
statusNormalStyle() { return 'background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15);color:' + this.brandConfig.primaryColor },
filteredCourses() {
switch (this.currentTab) {
case 'pending':
@@ -109,14 +115,24 @@
this.navBarHeight = navBarHeight
// 状态栏 + 导航栏 + tab行高度
this.totalHeaderHeight = statusBarHeight + navBarHeight + 44
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
brandStore.fetchConfig()
if (!store.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
this.loadCourses()
},
onPullDownRefresh() {
brandStore.fetchConfig()
this.loadCourses().finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goBack() { uni.navigateBack() },
goToDetail(id) {
+23 -13
View File
@@ -1,7 +1,7 @@
<template>
<view class="home-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title"> Novalon 教练端</text>
</view>
</view>
@@ -9,10 +9,10 @@
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<!-- 教练欢迎卡片 -->
<view class="greeting-card">
<view class="greeting-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="greeting-text">
<text class="greeting-label">{{ greetingLabel }}</text>
<text class="coach-name">{{ coachName }} 教练</text>
<text class="coach-name" :style="{ color: brandConfig.primaryColor }">{{ coachName }} 教练</text>
</view>
<view class="greeting-desc">
<text>祝您今日授课顺利</text>
@@ -22,17 +22,17 @@
<!-- 今日统计 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ todayStats.pending }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.pending }}</text>
<text class="stat-label">待开课</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ todayStats.inProgress }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.inProgress }}</text>
<text class="stat-label">进行中</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ todayStats.ended }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.ended }}</text>
<text class="stat-label">已结束</text>
</view>
</view>
@@ -43,15 +43,15 @@
</view>
<view class="entry-grid">
<view class="entry-card" @click="goToCourseList">
<view class="entry-icon-wrap green-bg">
<text class="entry-icon">&#9776;</text>
<view class="entry-icon-wrap green-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">&#9776;</text>
</view>
<text class="entry-name">我的团课</text>
<text class="entry-desc">查看和管理课程</text>
</view>
<view class="entry-card" @click="goToProfile">
<view class="entry-icon-wrap dark-bg">
<text class="entry-icon">&#9786;</text>
<view class="entry-icon-wrap dark-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.secondaryColorRgb + ',0.1)' }">
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">&#9786;</text>
</view>
<text class="entry-name">个人中心</text>
<text class="entry-desc">信息与违规记录</text>
@@ -67,6 +67,7 @@
<script>
const store = require('../../store/index')
const coachApi = require('../../api/coach')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -79,10 +80,13 @@
pending: 0,
inProgress: 0,
ended: 0
}
},
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
navStyle() { return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
greetingLabel() {
const hour = new Date().getHours()
if (hour < 6) return '夜深了,'
@@ -104,6 +108,11 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
if (!store.isLoggedIn) {
@@ -114,7 +123,8 @@
this.loadStats()
},
onPullDownRefresh() {
this.loadStats().finally(() => { uni.stopPullDownRefresh() })
Promise.all([this.loadStats(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToCourseList() {
+30 -31
View File
@@ -1,11 +1,12 @@
<template>
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: brandConfig.secondaryColor }">
<view class="brand-area">
<view class="logo-icon">
<text class="logo-symbol"></text>
<view class="logo-wrap" :style="{ backgroundColor: brandConfig.logoUrl ? 'transparent' : 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<image v-if="brandConfig.logoUrl" class="logo-img" :src="brandConfig.logoUrl" mode="aspectFit" />
<text v-else class="logo-symbol" :style="{ color: brandConfig.primaryColor }"></text>
</view>
<text class="app-name">Novalon 教练端</text>
<text class="app-slogan">高效管理 · 轻松授课</text>
<text class="app-name">{{ brandConfig.brandName }}</text>
<text class="app-slogan" v-if="brandConfig.slogan">{{ brandConfig.slogan }}</text>
</view>
<view class="login-form">
@@ -33,13 +34,13 @@
</text>
</view>
<button class="login-btn" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
<button class="login-btn" :style="loginBtnStyle" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
{{ loading ? '登录中...' : ' ' }}
</button>
</view>
<view class="footer-tip">
<text>Novalon 健身房管理系统</text>
<text>{{ brandConfig.brandName }}</text>
</view>
</view>
</template>
@@ -47,6 +48,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -55,9 +57,13 @@
username: '',
password: '',
showPassword: false,
loading: false
loading: false,
brandConfig: brandStore.config
}
},
computed: {
loginBtnStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor }
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 20
@@ -65,6 +71,11 @@
if (store.isLoggedIn) {
uni.reLaunch({ url: '/pages/index/index' })
}
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
methods: {
async handleLogin() {
@@ -113,7 +124,7 @@
<style scoped>
.login-page {
min-height: 100vh;
background: #F5F7FA;
background: #1A1A1A;
display: flex;
flex-direction: column;
align-items: center;
@@ -128,29 +139,18 @@
margin-top: 80px;
margin-bottom: 60px;
}
.logo-icon {
width: 80px;
height: 80px;
background: rgba(0,230,118,0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.logo-symbol {
font-size: 36px;
color: #00C853;
}
.logo-wrap { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; overflow: hidden; }
.logo-img { width: 100%; height: 100%; }
.logo-symbol { font-size: 52px; color: #00C853; }
.app-name {
font-size: 28px;
font-weight: 700;
color: #1E1E1E;
color: #FFFFFF;
letter-spacing: 1px;
}
.app-slogan {
font-size: 14px;
color: #7A7E84;
color: rgba(255,255,255,0.6);
margin-top: 8px;
letter-spacing: 2px;
}
@@ -164,28 +164,27 @@
.input-group {
width: 100%;
height: 52px;
background: #FFFFFF;
background: rgba(255,255,255,0.1);
border-radius: 40px;
display: flex;
align-items: center;
padding: 0 18px;
margin-bottom: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.input-icon {
font-size: 18px;
margin-right: 10px;
color: #7A7E84;
color: rgba(255,255,255,0.5);
}
.login-input {
flex: 1;
height: 100%;
font-size: 15px;
color: #1E1E1E;
color: #FFFFFF;
}
.toggle-pwd {
font-size: 18px;
color: #7A7E84;
color: rgba(255,255,255,0.5);
padding-left: 8px;
}
@@ -212,6 +211,6 @@
position: absolute;
bottom: 60px;
font-size: 12px;
color: #BDBDBD;
color: rgba(255,255,255,0.3);
}
</style>
@@ -1,6 +1,6 @@
<template>
<view class="profile-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">个人中心</text>
@@ -12,13 +12,13 @@
<!-- 教练信息卡片 -->
<view class="user-card">
<view class="user-header">
<view class="avatar">
<text class="avatar-text">CC</text>
<view class="avatar" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<text class="avatar-text" :style="{ color: brandConfig.primaryColor }">CC</text>
</view>
<view class="user-info">
<text class="user-name">{{ coachInfo.username }}</text>
<view class="user-tag">
<text>教练</text>
<text :style="{ color: brandConfig.primaryColor }">教练</text>
</view>
</view>
</view>
@@ -49,26 +49,26 @@
<text class="perf-number">{{ performance.attendanceRate != null ? performance.attendanceRate + '%' : 'N/A' }}</text>
<text class="perf-label">出勤率 </text>
<view v-if="performance.attendanceRate != null" class="perf-bar-wrap">
<view class="perf-bar" :style="{ width: performance.attendanceRate + '%', background: getRateColor(performance.attendanceRate) }"></view>
<view class="perf-bar" :style="attendanceBarStyle"></view>
</view>
</view>
<view class="perf-item" @click="showFormula('fullness')">
<text class="perf-number">{{ performance.fullnessRate != null ? performance.fullnessRate + '%' : 'N/A' }}</text>
<text class="perf-label">满员率 </text>
<view v-if="performance.fullnessRate != null" class="perf-bar-wrap">
<view class="perf-bar" :style="{ width: performance.fullnessRate + '%', background: getRateColor(performance.fullnessRate) }"></view>
<view class="perf-bar" :style="fullnessBarStyle"></view>
</view>
</view>
</view>
<view class="score-row" @click="showFormula('composite')">
<text class="score-label">综合评分 </text>
<text class="score-badge" :class="scoreClass">{{ scoreText }}</text>
<text class="score-badge" :class="scoreClass" :style="scoreClass === 'score-great' ? scoreGreatStyle : ''">{{ scoreText }}</text>
<text v-if="scoreText !== '--'" class="score-icon"></text>
</view>
</view>
<!-- 违规统计 -->
<view v-if="violations.length > 0" class="stats-card">
<view v-if="violations.length > 0" class="stats-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="stat-item">
<text class="stat-number">{{ violations.length }}</text>
<text class="stat-label">违规次数</text>
@@ -105,7 +105,7 @@
<text class="formula-example">{{ formulaExample }}</text>
</view>
<view class="formula-footer">
<button class="formula-btn" @click="closeFormula">知道了</button>
<button class="formula-btn" :style="{ backgroundColor: brandConfig.primaryColor }" @click="closeFormula">知道了</button>
</view>
</view>
</view>
@@ -124,6 +124,7 @@
<script>
const store = require('../../store/index')
const coachApi = require('../../api/coach')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -150,9 +151,16 @@
formulaVisible: false,
formulaTitle: '',
formulaContent: '',
formulaExample: ''
formulaExample: '',
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
scoreGreatStyle() { return 'color:' + this.brandConfig.primaryColor + ';background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15)' },
attendanceBarStyle() { return { width: this.performance.attendanceRate + '%', background: this.getRateColor(this.performance.attendanceRate) } },
fullnessBarStyle() { return { width: this.performance.fullnessRate + '%', background: this.getRateColor(this.performance.fullnessRate) } }
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
@@ -166,8 +174,14 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
brandStore.fetchConfig()
if (!store.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
return
@@ -178,6 +192,12 @@
},
methods: {
goBack() { uni.navigateBack() },
getRateColor(rate) {
if (rate == null) return '#CCCCCC'
if (rate >= 80) return this.brandConfig.primaryColor
if (rate >= 60) return '#FF9800'
return '#F44336'
},
async loadPerformance() {
try {
+86
View File
@@ -0,0 +1,86 @@
const brandApi = require('../api/brand')
const BRAND_CACHE_KEY = 'brand_config_cache'
const DEFAULT_CONFIG = {
brandName: 'Novalon教练端',
slogan: '高效管理 · 轻松授课',
primaryColor: '#00E676',
primaryColorRgb: '0,230,118',
secondaryColor: '#1A1A1A',
secondaryColorRgb: '26,26,26',
logoUrl: '',
backgroundImageUrl: '',
updatedAt: ''
}
/**
* 品牌方案 Store
*
* 响应式策略:fetchConfig 更新后通过 uni.$emit('brand:updated', config) 广播,
* 页面通过 uni.$on 监听并在 data 中维护副本,确保 UI 自动刷新。
*/
const brandStore = {
_config: null,
_loaded: false,
/** 获取当前品牌配置(优先内存,其次缓存,最后默认值) */
get config() {
if (this._config) return this._config
try {
const cached = uni.getStorageSync(BRAND_CACHE_KEY)
if (cached && cached.primaryColor) {
this._config = cached
return cached
}
} catch (e) { /* ignore */ }
return { ...DEFAULT_CONFIG }
},
/** 是否已从后端加载完成 */
get isLoaded() {
return this._loaded
},
/**
* 从后端拉取品牌配置
* 策略:对比 updatedAt,有变化时更新缓存并广播事件通知全部页面
* @returns {Promise<boolean>} 是否有变化
*/
async fetchConfig() {
try {
const res = await brandApi.getBrandConfig()
if (res) {
const newConfig = {
brandName: res.brandName || DEFAULT_CONFIG.brandName,
slogan: res.slogan || DEFAULT_CONFIG.slogan,
primaryColor: res.primaryColor || DEFAULT_CONFIG.primaryColor,
primaryColorRgb: res.primaryColorRgb || DEFAULT_CONFIG.primaryColorRgb,
secondaryColor: res.secondaryColor || DEFAULT_CONFIG.secondaryColor,
secondaryColorRgb: res.secondaryColorRgb || DEFAULT_CONFIG.secondaryColorRgb,
logoUrl: res.logoUrl || '',
backgroundImageUrl: res.backgroundImageUrl || '',
updatedAt: res.updatedAt || ''
}
if (!this._config || this._config.updatedAt !== newConfig.updatedAt) {
this._config = newConfig
try {
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
} catch (e) { /* ignore */ }
this._loaded = true
// 广播事件通知所有页面刷新
uni.$emit('brand:updated', newConfig)
return true
}
}
} catch (e) {
console.error('[BrandStore] 获取品牌配置失败:', e)
}
this._loaded = true
return false
}
}
module.exports = brandStore
+14
View File
@@ -1,10 +1,24 @@
<script>
const brandStore = require('./store/brand')
export default {
onLaunch: function() {
console.log('App Launch')
// 冷启动时拉取品牌配置(以缓存为准立即显示,后台拉取最新)
brandStore.fetchConfig().then(changed => {
if (changed) {
brandStore.applyTabBar()
}
})
},
onShow: function() {
console.log('App Show')
// 每次回到前台拉取最新配置
brandStore.fetchConfig().then(changed => {
if (changed) {
brandStore.applyTabBar()
}
})
},
onHide: function() {
console.log('App Hide')
+11
View File
@@ -0,0 +1,11 @@
const http = require('../utils/request')
module.exports = {
/**
* 获取品牌配置(需 JWT 登录态,自动按 tenantId 返回对应租户配置)
* @returns {Promise<Object>} 品牌配置对象
*/
getBrandConfig() {
return http.get('/brand')
}
}
@@ -173,6 +173,90 @@ async function signIn(courseId, memberId, token) {
return res;
}
// ── 品牌定制 API ──
/** 获取品牌配置 (需auth) */
async function getBrandConfig(token) {
console.log('[API] 获取品牌配置...');
const res = await makeRequest('GET', `${API_BASE}/brand`, null, token);
console.log(`[API] 品牌配置: status=${res.status}`);
return res;
}
// ── 通知/消息 API ──
/** 获取活跃Banner列表 */
async function getActiveBanners(token) {
console.log('[API] 获取活跃Banners...');
const res = await makeRequest('GET', `${API_BASE}/banners/active`, null, token);
console.log(`[API] Banners: status=${res.status}`);
return res;
}
/** 获取系统通知列表 */
async function getSystemNotices(token) {
console.log('[API] 获取系统通知...');
const res = await makeRequest('GET', `${API_BASE}/notices`, null, token);
console.log(`[API] 通知列表: status=${res.status}`);
return res;
}
/** 获取用户消息列表 */
async function getUserMessages(userId, token) {
console.log(`[API] 获取用户消息: userId=${userId}`);
const res = await makeRequest('GET', `${API_BASE}/messages/user/${userId}`, null, token);
console.log(`[API] 用户消息: status=${res.status}`);
return res;
}
/** 获取用户未读消息数 */
async function getUnreadMessageCount(userId, token) {
console.log(`[API] 获取未读消息数: userId=${userId}`);
const res = await makeRequest('GET', `${API_BASE}/messages/user/${userId}/unread`, null, token);
console.log(`[API] 未读数: status=${res.status}`);
return res;
}
/** 标记单条消息已读 */
async function markMessageAsRead(messageId, token) {
console.log(`[API] 标记消息已读: id=${messageId}`);
const res = await makeRequest('PUT', `${API_BASE}/messages/${messageId}/read`, null, token);
console.log(`[API] 标记已读: status=${res.status}`);
return res;
}
// ── 团课高级搜索 API ──
/** 按课程类型筛选 */
async function searchCoursesByType(typeId, token) {
console.log(`[API] 按类型搜索: typeId=${typeId}`);
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
page: 0, size: 50, courseTypeId: typeId
}, token);
console.log(`[API] 类型筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
return res;
}
/** 按标签筛选 */
async function searchCoursesByLabel(labelId, token) {
console.log(`[API] 按标签搜索: labelId=${labelId}`);
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
page: 0, size: 50, labelId: labelId
}, token);
console.log(`[API] 标签筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
return res;
}
/** 复筛: 类型+标签 */
async function searchCoursesCombined(typeId, labelId, token) {
console.log(`[API] 组合搜索: typeId=${typeId}, labelId=${labelId}`);
const res = await makeRequest('POST', `${API_BASE}/groupCourse/page`, {
page: 0, size: 50, courseTypeId: typeId, labelId: labelId
}, token);
console.log(`[API] 组合筛选: status=${res.status}, total=${res.data?.data?.totalElements || '?'}`);
return res;
}
module.exports = {
makeRequest,
adminLogin,
@@ -185,6 +269,15 @@ module.exports = {
bookCourse,
getMemberBookings,
signIn,
getBrandConfig,
getActiveBanners,
getSystemNotices,
getUserMessages,
getUnreadMessageCount,
markMessageAsRead,
searchCoursesByType,
searchCoursesByLabel,
searchCoursesCombined,
isSuccess,
BASE_URL,
API_BASE
@@ -0,0 +1,194 @@
/**
* P4-API - 会员端团课高级搜索 API 测试
* 流程:会员登录 → 按课程类型筛选 → 按标签筛选 → 类型+标签组合筛选 → 验证结果
*
* 使用 HTTP APIHMAC-SHA256 签名 + JWT Token
* 前置条件:后端服务 http://192.168.110.64:8084 已启动
*/
const api = require('../helpers/api-helper');
let authToken = null;
let memberId = null;
let typeId = null;
let labelId = null;
describe('P4-API - 团课高级搜索', () => {
// ════════════════════════════════════════════════════
// 步骤0:登录 & 准备筛选条件
// ════════════════════════════════════════════════════
beforeAll(async () => {
console.log('════════════════════════════════════════════');
console.log('P4-API - 团课高级搜索测试');
console.log('════════════════════════════════════════════');
const result = await api.memberLogin();
expect(result).not.toBeNull();
authToken = result.accessToken;
memberId = result.memberId;
console.log(`[P4-API] 会员登录成功: memberId=${memberId}`);
// 先获取课程类型列表,找第一个类型ID
const typeRes = await api.getCourseTypes();
if (api.isSuccess(typeRes)) {
const types = typeRes.data?.data || typeRes.data || [];
const records = Array.isArray(types) ? types : (types.records || types.content || []);
if (records.length > 0) {
typeId = records[0].id;
console.log(`[P4-API] 选择课程类型: id=${typeId}, name=${records[0].typeName || records[0].name}`);
}
}
// 尝试从课程列表获取第一个标签ID
try {
const searchRes = await api.searchCourses('', authToken);
if (api.isSuccess(searchRes)) {
const content = searchRes.data?.data?.content || searchRes.data?.content || searchRes.data || [];
const courses = Array.isArray(content) ? content : (content.records || []);
// 从第一个课程取标签
for (const course of courses) {
const labels = course.labels || course.labelList || [];
if (Array.isArray(labels) && labels.length > 0) {
const firstLabel = labels[0];
labelId = typeof firstLabel === 'object' ? firstLabel.id : firstLabel;
const name = typeof firstLabel === 'object' ? firstLabel.labelName : '';
console.log(`[P4-API] 选择标签: id=${labelId}, name=${name}`);
break;
}
}
}
} catch (e) {
console.warn('[P4-API] 获取标签失败:', e.message);
}
}, 60000);
// ════════════════════════════════════════════════════
// 步骤1:按课程类型筛选
// ════════════════════════════════════════════════════
describe('按课程类型筛选', () => {
test('TC-SEARCH-001: 按类型筛选应返回正确结果', async () => {
if (!typeId) {
console.warn('[TC-SEARCH-001] 无类型ID,跳过');
return;
}
const res = await api.searchCoursesByType(typeId, authToken);
expect(res.status).toBe(200);
const content = res.data?.data?.content || res.data?.content || res.data || [];
const courses = Array.isArray(content) ? content : (content.records || []);
console.log(`[TC-SEARCH-001] 类型筛选结果: ${courses.length} 个课程`);
// 验证所有返回课程的类型匹配
if (courses.length > 0) {
courses.slice(0, 3).forEach(c => {
console.log(` - ${c.courseName || c.name} (typeId=${c.courseTypeId || c.typeId})`);
});
}
expect(courses.length).toBeGreaterThanOrEqual(0);
});
});
// ════════════════════════════════════════════════════
// 步骤2:按标签筛选
// ════════════════════════════════════════════════════
describe('按标签筛选', () => {
test('TC-SEARCH-002: 按标签筛选应返回正确结果', async () => {
if (!labelId) {
console.warn('[TC-SEARCH-002] 无标签ID,跳过');
return;
}
const res = await api.searchCoursesByLabel(labelId, authToken);
expect(res.status).toBe(200);
const content = res.data?.data?.content || res.data?.content || res.data || [];
const courses = Array.isArray(content) ? content : (content.records || []);
console.log(`[TC-SEARCH-002] 标签筛选结果: ${courses.length} 个课程`);
if (courses.length > 0) {
courses.slice(0, 3).forEach(c => {
const labels = (c.labels || c.labelList || []).map(l =>
typeof l === 'object' ? l.labelName : l
);
console.log(` - ${c.courseName || c.name} (labels: [${labels.join(', ')}])`);
});
}
});
});
// ════════════════════════════════════════════════════
// 步骤3:类型+标签组合筛选
// ════════════════════════════════════════════════════
describe('类型+标签组合筛选', () => {
test('TC-SEARCH-003: 组合筛选应返回交集结果', async () => {
if (!typeId || !labelId) {
console.warn('[TC-SEARCH-003] 缺少类型或标签ID,跳过');
return;
}
const res = await api.searchCoursesCombined(typeId, labelId, authToken);
expect(res.status).toBe(200);
const content = res.data?.data?.content || res.data?.content || res.data || [];
const courses = Array.isArray(content) ? content : (content.records || []);
console.log(`[TC-SEARCH-003] 组合筛选结果: ${courses.length} 个课程`);
if (courses.length > 0) {
courses.slice(0, 3).forEach(c => {
console.log(` - ${c.courseName || c.name} (id=${c.id})`);
});
}
});
});
// ════════════════════════════════════════════════════
// 步骤4:边界场景
// ════════════════════════════════════════════════════
describe('边界场景', () => {
test('TC-SEARCH-004: 搜索不存在类型返回200(后端可能忽略无效筛选)', async () => {
const res = await api.searchCoursesByType(99999, authToken);
// 应确保API正常响应,即使筛选参数无效也不应该崩溃
console.log(`[TC-SEARCH-004] 不存在类型: status=${res.status}`);
expect(res.status).toBe(200);
});
test('TC-SEARCH-005: 搜索不存在标签返回200(后端可能忽略无效筛选)', async () => {
const res = await api.searchCoursesByLabel(99999, authToken);
console.log(`[TC-SEARCH-005] 不存在标签: status=${res.status}`);
expect(res.status).toBe(200);
});
test('TC-SEARCH-006: 空关键词搜索可返回全部课程', async () => {
const res = await api.searchCourses('', authToken);
expect(res.status).toBe(200);
const content = res.data?.data?.content || res.data?.content || res.data || [];
const courses = Array.isArray(content) ? content : (content.records || []);
console.log(`[TC-SEARCH-006] 空搜索返回: ${courses.length} 个课程`);
expect(courses.length).toBeGreaterThanOrEqual(0);
});
});
// ════════════════════════════════════════════════════
// 步骤5:总结
// ════════════════════════════════════════════════════
describe('总结', () => {
test('TC-P4-SUMMARY: 团课高级搜索汇总', () => {
console.log('');
console.log('════════════════════════════════════════════');
console.log('P4-API - 团课高级搜索测试结束');
console.log('════════════════════════════════════════════');
console.log(' 已测试:');
console.log(` 1. 按类型筛选 (typeId=${typeId || 'N/A'})`);
console.log(` 2. 按标签筛选 (labelId=${labelId || 'N/A'})`);
console.log(` 3. 类型+标签组合筛选`);
console.log(' 4. 不存在类型/标签边界');
console.log(' 5. 空关键词搜索');
console.log('════════════════════════════════════════════');
expect(memberId).toBeDefined();
expect(authToken).toBeTruthy();
});
});
});
@@ -0,0 +1,179 @@
/**
* P3-API - 会员端品牌定制 & 通知消息 API 测试
* 流程会员登录 获取品牌配置 获取Banner/通知
*
* 使用 HTTP APIHMAC-SHA256 签名 + JWT Token
* 前置条件后端服务 http://192.168.110.64:8084 已启动
*/
const api = require('../helpers/api-helper');
let authToken = null;
let memberId = null;
describe('P3-API - 会员端品牌定制 & 通知消息', () => {
// ════════════════════════════════════════════════════
// 步骤0:会员登录
// ════════════════════════════════════════════════════
beforeAll(async () => {
console.log('════════════════════════════════════════════');
console.log('P3-API - 品牌定制 & 通知消息测试');
console.log('════════════════════════════════════════════');
const result = await api.memberLogin();
expect(result).not.toBeNull();
authToken = result.accessToken;
memberId = result.memberId;
console.log(`[P3-API] 会员登录成功: memberId=${memberId}`);
}, 30000);
// ════════════════════════════════════════════════════
// 步骤1:品牌定制配置
// ════════════════════════════════════════════════════
describe('品牌定制配置', () => {
test('TC-BRAND-001: 获取品牌配置', async () => {
const res = await api.getBrandConfig(authToken);
console.log(`[TC-BRAND-001] status=${res.status}`);
if (api.isSuccess(res)) {
const brand = res.data?.data || res.data || {};
console.log(` 品牌名称: ${brand.brandName || '(未设置)'}`);
console.log(` 品牌口号: ${brand.slogan || '(未设置)'}`);
console.log(` 主色调: ${brand.primaryColor || '(默认)'}`);
console.log(` 辅助色: ${brand.secondaryColor || '(默认)'}`);
console.log(` Logo URL: ${brand.logoUrl || '(默认)'}`);
console.log(` 背景URL: ${brand.backgroundUrl || '(默认)'}`);
// 品牌名称应存在(至少默认值)
expect(brand).toBeDefined();
}
});
test('TC-BRAND-002: 验证品牌颜色为合法HEX值', async () => {
const res = await api.getBrandConfig(authToken);
if (!api.isSuccess(res)) {
console.warn('[TC-BRAND-002] 获取品牌配置失败,跳过');
return;
}
const brand = res.data?.data || res.data || {};
const primaryColor = brand.primaryColor;
const secondaryColor = brand.secondaryColor;
if (primaryColor) {
// 验证是合法 HEX (#RRGGBB 或 #RRGGBBAA)
expect(/^#[0-9A-Fa-f]{6,8}$/.test(primaryColor)).toBe(true);
console.log(` 主色调 HEX 合法: ${primaryColor}`);
}
if (secondaryColor) {
expect(/^#[0-9A-Fa-f]{6,8}$/.test(secondaryColor)).toBe(true);
console.log(` 辅助色 HEX 合法: ${secondaryColor}`);
}
});
test('TC-BRAND-003: Logo URL 应为有效路径或默认占位符', async () => {
const res = await api.getBrandConfig(authToken);
if (!api.isSuccess(res)) {
console.warn('[TC-BRAND-003] 获取品牌配置失败,跳过');
return;
}
const brand = res.data?.data || res.data || {};
const logoUrl = brand.logoUrl;
if (logoUrl) {
// 可以是完整URL、相对路径、或base64
const isValid = typeof logoUrl === 'string' && logoUrl.length > 0;
expect(isValid).toBe(true);
console.log(` Logo URL: ${logoUrl.substring(0, 80)}...`);
} else {
console.log(' Logo URL 为空(使用默认值)');
}
});
});
// ════════════════════════════════════════════════════
// 步骤2Banner & 系统通知
// ════════════════════════════════════════════════════
describe('Banner & 系统通知', () => {
test('TC-NOTIFY-001: 获取活跃Banner列表', async () => {
const res = await api.getActiveBanners(authToken);
console.log(`[TC-NOTIFY-001] status=${res.status}`);
if (api.isSuccess(res)) {
const banners = res.data?.data || res.data || [];
const records = Array.isArray(banners) ? banners : (banners.records || []);
console.log(` 活跃Banner数量: ${records.length}`);
if (records.length > 0) {
const b = records[0];
console.log(` 首条Banner: title="${b.title}", imageUrl=${(b.imageUrl || '').substring(0, 50)}`);
}
}
});
test('TC-NOTIFY-002: 获取系统通知列表', async () => {
const res = await api.getSystemNotices(authToken);
console.log(`[TC-NOTIFY-002] status=${res.status}`);
if (api.isSuccess(res)) {
const notices = res.data?.data || res.data || [];
const records = Array.isArray(notices) ? notices : (notices.records || []);
console.log(` 系统通知数量: ${records.length}`);
if (records.length > 0) {
const n = records[0];
console.log(` 首条通知: title="${n.noticeTitle || n.title}", status=${n.status}`);
}
}
});
test('TC-NOTIFY-003: 获取用户消息列表', async () => {
const res = await api.getUserMessages(memberId, authToken);
console.log(`[TC-NOTIFY-003] status=${res.status}`);
if (api.isSuccess(res)) {
const messages = res.data?.data || res.data || [];
const records = Array.isArray(messages) ? messages : (messages.records || []);
console.log(` 消息数量: ${records.length}`);
}
// 消息可能为空,不强制要求有数据
});
test('TC-NOTIFY-004: 获取未读消息数', async () => {
const res = await api.getUnreadMessageCount(memberId, authToken);
console.log(`[TC-NOTIFY-004] status=${res.status}`);
if (api.isSuccess(res)) {
const count = Number(res.data?.data ?? res.data ?? 0);
console.log(` 未读消息数: ${count}`);
expect(typeof count).toBe('number');
expect(count).toBeGreaterThanOrEqual(0);
}
});
});
// ════════════════════════════════════════════════════
// 步骤3:总结
// ════════════════════════════════════════════════════
describe('总结', () => {
test('TC-P3-SUMMARY: 品牌定制 & 通知消息测试汇总', () => {
console.log('');
console.log('════════════════════════════════════════════');
console.log('P3-API - 品牌定制 & 通知消息测试结束');
console.log('════════════════════════════════════════════');
console.log(' 已测试:');
console.log(' 1. 品牌配置获取 (名称/颜色/Logo/背景)');
console.log(' 2. 颜色HEX格式校验');
console.log(' 3. Logo URL有效性');
console.log(' 4. 活跃Banner列表');
console.log(' 5. 系统通知列表');
console.log(' 6. 用户消息列表');
console.log(' 7. 未读消息数量');
console.log('════════════════════════════════════════════');
expect(memberId).toBeDefined();
expect(authToken).toBeTruthy();
});
});
});
@@ -1,7 +1,7 @@
<template>
<view class="detail-root">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="back-btn" @click="goBack">&#8249;</text>
<text class="nav-title">课程详情</text>
</view>
@@ -10,7 +10,7 @@
<view class="cover-area">
<image v-if="course.coverImage" class="cover-img" :src="course.coverImage" mode="aspectFill"
@error="course.coverError = true" />
<view v-if="!course.coverImage || course.coverError" class="cover-bg">
<view v-if="!course.coverImage || course.coverError" class="cover-bg" :style="{ background: coverGradient }">
<text class="cover-text">{{ course.typeName ? course.typeName.slice(0, 2) : '课程' }}</text>
</view>
<view class="status-tag" :class="course.statusClass" v-if="course.statusText">{{ course.statusText }}</view>
@@ -117,7 +117,7 @@
<text class="bottom-desc">/ </text>
</view>
<button class="booking-btn" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
@click="handleBooking">
@click="handleBooking" :style="course.canBook ? bookingBtnStyle : ''">
{{ course.btnText }}
</button>
</view>
@@ -128,6 +128,7 @@
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
const { resolveCoverUrl } = require('../../utils/request')
export default {
@@ -166,7 +167,8 @@
coverImage: '',
coverError: false
},
realMemberCount: 0
realMemberCount: 0,
brandConfig: brandStore.config
}
},
onLoad(options) {
@@ -183,6 +185,24 @@
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
if (options.id) this.loadCourseDetail(options.id)
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
computed: {
coverGradient() {
return 'linear-gradient(135deg, ' + this.brandConfig.primaryColor + ', ' + this.brandConfig.secondaryColor + ')'
},
headerStyle() {
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
navStyle() {
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
bookingBtnStyle() {
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
}
},
methods: {
async loadCourseDetail(id) {
+53 -10
View File
@@ -1,32 +1,41 @@
<template>
<view class="home-page">
<!-- 状态栏 + 胶囊按钮占位 -->
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title">&#10031; 今日训练</text>
</view>
</view>
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<!-- 品牌展示 -->
<view class="brand-section">
<image class="brand-logo" :src="brandConfig.logoUrl || '/static/logo.png'" mode="aspectFit" />
<view class="brand-text">
<text class="brand-name">{{ brandConfig.brandName }}</text>
<text class="brand-slogan">{{ brandConfig.slogan }}</text>
</view>
</view>
<!-- 个人信息卡片深色+ 轮播图 -->
<view class="greeting-card">
<view class="greeting-card" :style="greetingCardStyle">
<view class="greeting-header" @click="goToProfile">
<view class="member-info">
<text class="member-name">{{ memberInfo.nickname ? '你好,' + memberInfo.nickname : '欢迎您!点击前往更新您的信息。' }}</text>
</view>
<view class="checkin-badge" v-if="memberInfo.checkedInToday">
<view class="checkin-badge" v-if="memberInfo.checkedInToday" :style="checkinBadgeStyle">
<text class="checkin-icon">&#10003;</text>
<text>今日已签到</text>
</view>
</view>
<view class="stats">
<view class="stat-item">
<text class="stat-number">{{ memberInfo.totalSignDays }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ memberInfo.totalSignDays }}</text>
<text class="stat-label">累计签到()</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ memberInfo.pendingBookings }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ memberInfo.pendingBookings }}</text>
<text class="stat-label">待上团课</text>
</view>
</view>
@@ -46,7 +55,7 @@
<view class="recommend-section">
<view class="section-header">
<text class="section-title">&#10031; 今日推荐</text>
<text class="section-more" @click="goToSearch">查看全部 &#8250;</text>
<text class="section-more" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">查看全部 &#8250;</text>
</view>
<view class="recommend-list">
<view v-for="(course, idx) in recommendCourses" :key="idx" class="recommend-card"
@@ -56,7 +65,7 @@
<text class="card-name">{{ course.courseName }}</text>
<text class="card-reason">{{ course.recommendReason }}</text>
<view class="card-bottom">
<text class="card-price">{{ course.priceLabel }}</text>
<text class="card-price" :style="{ color: brandConfig.primaryColor }">{{ course.priceLabel }}</text>
<text class="card-count">{{ course.bookedCount }}人已预约</text>
</view>
</view>
@@ -75,6 +84,7 @@
const memberApi = require('../../api/member')
const courseApi = require('../../api/course')
const bannerApi = require('../../api/banner')
const brandStore = require('../../store/brand')
const { resolveCoverUrl } = require('../../utils/request')
export default {
@@ -91,7 +101,8 @@
pendingBookings: 0
},
banners: [],
recommendCourses: []
recommendCourses: [],
brandConfig: brandStore.config
}
},
onLoad() {
@@ -107,6 +118,29 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
computed: {
headerStyle() {
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
navStyle() {
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
greetingCardStyle() {
return {
backgroundColor: this.brandConfig.secondaryColor,
backgroundImage: this.brandConfig.backgroundImageUrl ? 'url(' + this.brandConfig.backgroundImageUrl + ')' : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center'
}
},
checkinBadgeStyle() {
return { backgroundColor: 'rgba(' + this.brandConfig.primaryColorRgb + ',0.2)', color: this.brandConfig.primaryColor }
}
},
onShow() {
if (!store.isLoggedIn) {
@@ -114,9 +148,11 @@
return
}
this.loadData()
brandStore.fetchConfig()
},
onPullDownRefresh() {
this.loadData().finally(() => { uni.stopPullDownRefresh() })
Promise.all([this.loadData(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToCourseDetail(id) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + id }) },
@@ -193,6 +229,13 @@
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
.content-inner { padding: 16px; }
/* 品牌展示 */
.brand-section { display: flex; align-items: center; background: #FFFFFF; border-radius: 16px; padding: 16px 18px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.brand-logo { width: 48px; height: 48px; border-radius: 10px; margin-right: 14px; flex-shrink: 0; }
.brand-text { display: flex; flex-direction: column; }
.brand-name { font-size: 18px; font-weight: 700; color: #1E1E1E; }
.brand-slogan { font-size: 12px; color: #9E9E9E; margin-top: 4px; }
/* 个人信息卡片 */
.greeting-card { background: #1A1A1A; border-radius: 20px; padding: 18px 20px 0; color: #FFFFFF; margin-bottom: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); overflow: hidden; }
.greeting-header { display: flex; justify-content: space-between; align-items: flex-start; }
+51 -37
View File
@@ -1,30 +1,32 @@
<template>
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: brandConfig.secondaryColor }">
<view class="brand-area">
<view class="logo-icon">
<text class="logo-symbol"></text>
<view class="logo-wrap" :style="{ backgroundColor: brandConfig.logoUrl ? 'transparent' : 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<image v-if="brandConfig.logoUrl" class="logo-img" :src="brandConfig.logoUrl" mode="aspectFit" />
<text v-else class="logo-symbol" :style="{ color: brandConfig.primaryColor }"></text>
</view>
<text class="app-name">Novalon 健身房</text>
<text class="app-slogan">专业预约 · 科学训练</text>
<text class="app-name">{{ brandConfig.brandName }}</text>
<text class="app-slogan" v-if="brandConfig.slogan">{{ brandConfig.slogan }}</text>
</view>
<view class="login-btn-area">
<button class="wechat-login-btn" @click="handleLogin">
<text class="wechat-symbol"></text>
<text class="btn-text">信一键登录</text>
<button class="wechat-login-btn" @click="handleLogin"
:style="{ background: brandConfig.primaryColor }">
<text class="wechat-symbol" :style="{ color: brandConfig.secondaryColor }"></text>
<text class="btn-text" :style="{ color: brandConfig.secondaryColor }">微信一键登录</text>
</button>
<view class="agreement-row">
<view class="checkbox-wrapper" @click="toggleAgree">
<view class="checkbox" :class="{ checked: agreed }">
<text v-if="agreed" class="check-mark">&#10003;</text>
<view class="checkbox" :class="{ checked: agreed }" :style="agreed ? agreedStyle : ''">
<text v-if="agreed" class="check-mark" :style="{ color: brandConfig.secondaryColor }">&#10003;</text>
</view>
</view>
<text class="agreement-text">
已阅读并同意
<text class="link">用户协议</text>
<text class="link" :style="{ color: brandConfig.primaryColor }">用户协议</text>
<text class="link">隐私政策</text>
<text class="link" :style="{ color: brandConfig.primaryColor }">隐私政策</text>
</text>
</view>
</view>
@@ -50,13 +52,15 @@
<script>
const authApi = require('../../api/auth')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
return {
statusBarHeight: 0,
agreed: false,
loading: false
loading: false,
brandConfig: brandStore.config
}
},
onLoad() {
@@ -66,6 +70,15 @@
if (store.isLoggedIn) {
uni.switchTab({ url: '/pages/index/index' })
}
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
computed: {
agreedStyle() {
return 'background:' + this.brandConfig.primaryColor + ';border-color:' + this.brandConfig.secondaryColor
}
},
methods: {
toggleAgree() { this.agreed = !this.agreed },
@@ -119,32 +132,33 @@
.login-page { min-height: 100vh; background: #F5F7FA; display: flex; flex-direction: column; align-items: center; padding: 0 32px; overflow-x: hidden; }
.brand-area { display: flex; flex-direction: column; align-items: center; margin-top: 60px; margin-bottom: 80px; }
.logo-icon { width: 80px; height: 80px; background: rgba(0,230,118,0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
.logo-symbol { font-size: 36px; color: #00C853; }
.app-name { font-size: 28px; font-weight: 700; color: #1E1E1E; letter-spacing: 1px; }
.app-slogan { font-size: 14px; color: #7A7E84; margin-top: 8px; letter-spacing: 2px; }
.logo-wrap { width: 120px; height: 120px; background: rgba(0,230,118,0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; overflow: hidden; }
.logo-img { width: 100%; height: 100%; }
.logo-symbol { font-size: 52px; color: #00C853; }
.app-name { font-size: 28px; font-weight: 700; color: #FFFFFF; letter-spacing: 1px; }
.app-slogan { font-size: 14px; color: rgba(255,255,255,0.6); margin-top: 8px; letter-spacing: 2px; }
.login-btn-area { width: 100%; display: flex; flex-direction: column; align-items: center; }
.wechat-login-btn { width: 100%; height: 52px; background: #00E676; border-radius: 40px; display: flex; align-items: center; justify-content: center; border: none; padding: 0; box-shadow: 0 4px 16px rgba(0,230,118,0.35); }
.wechat-login-btn::after { border: none; }
.wechat-symbol { font-size: 18px; font-weight: 700; color: #1A1A1A; margin-right: 8px; }
.btn-text { font-size: 17px; font-weight: 700; color: #1A1A1A; }
.login-btn-area { width: 100%; display: flex; flex-direction: column; align-items: center; }
.wechat-login-btn { width: 100%; height: 52px; background: #00E676; border-radius: 40px; display: flex; align-items: center; justify-content: center; border: none; padding: 0; box-shadow: 0 4px 16px rgba(0,230,118,0.35); }
.wechat-login-btn::after { border: none; }
.wechat-symbol { font-size: 18px; font-weight: 700; color: #1A1A1A; margin-right: 8px; }
.btn-text { font-size: 17px; font-weight: 700; color: #1A1A1A; }
.agreement-row { display: flex; align-items: center; margin-top: 16px; }
.checkbox-wrapper { margin-right: 6px; }
.checkbox { width: 18px; height: 18px; border-radius: 50%; border: 2px solid #BDBDBD; display: flex; align-items: center; justify-content: center; }
.checkbox.checked { background: #00E676; border-color: #00E676; }
.check-mark { font-size: 12px; color: #1A1A1A; font-weight: 700; }
.agreement-text { font-size: 12px; color: #7A7E84; }
.link { color: #00C853; }
.agreement-row { display: flex; align-items: center; margin-top: 16px; }
.checkbox-wrapper { margin-right: 6px; }
.checkbox { width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.3); display: flex; align-items: center; justify-content: center; }
.checkbox.checked { background: #00E676; border-color: #00E676; }
.check-mark { font-size: 12px; color: #1A1A1A; font-weight: 700; }
.agreement-text { font-size: 12px; color: rgba(255,255,255,0.5); }
.link { color: #00E676; }
.other-login { margin-top: 60px; width: 100%; display: flex; flex-direction: column; align-items: center; }
.divider-text { font-size: 13px; color: #BDBDBD; margin-bottom: 20px; }
.other-icons { display: flex; }
.other-item { display: flex; flex-direction: column; align-items: center; }
.other-icon-circle { width: 48px; height: 48px; background: #FFFFFF; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.other-symbol { font-size: 22px; color: #7A7E84; }
.other-label { font-size: 12px; color: #7A7E84; }
.other-login { margin-top: 60px; width: 100%; display: flex; flex-direction: column; align-items: center; }
.divider-text { font-size: 13px; color: rgba(255,255,255,0.3); margin-bottom: 20px; }
.other-icons { display: flex; }
.other-item { display: flex; flex-direction: column; align-items: center; }
.other-icon-circle { width: 48px; height: 48px; background: rgba(255,255,255,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
.other-symbol { font-size: 22px; color: rgba(255,255,255,0.6); }
.other-label { font-size: 12px; color: rgba(255,255,255,0.4); }
.footer-tip { position: absolute; bottom: 60px; font-size: 12px; color: #BDBDBD; }
.footer-tip { position: absolute; bottom: 60px; font-size: 12px; color: rgba(255,255,255,0.25); }
</style>
@@ -1,7 +1,7 @@
<template>
<view class="my-courses-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title">&#8981; 我的课程</text>
</view>
</view>
@@ -53,7 +53,7 @@
<view v-if="!loading && bookings.length === 0" class="empty-state">
<text class="empty-symbol">&#8857;</text>
<text class="empty-text">暂无预约课程</text>
<text class="empty-sub" @click="goToSearch">前往预约团课 &#8250;</text>
<text class="empty-sub" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">前往预约团课 &#8250;</text>
</view>
<view class="bottom-safe"></view>
@@ -65,6 +65,7 @@
<script>
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -75,7 +76,8 @@
loading: true,
cancelling: null,
signing: null,
bookings: []
bookings: [],
brandConfig: brandStore.config
}
},
onLoad() {
@@ -91,13 +93,26 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
computed: {
headerStyle() {
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
navStyle() {
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
}
},
onShow() {
if (!store.isLoggedIn) return
this.loadBookings()
},
onPullDownRefresh() {
this.loadBookings().finally(() => { uni.stopPullDownRefresh() })
Promise.all([this.loadBookings(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToSearch() { uni.switchTab({ url: '/pages/search/search' }) },
+57 -33
View File
@@ -1,7 +1,7 @@
<template>
<view class="profile-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title">&#9679; 个人中心</text>
<text class="settings-symbol" @click="goToSettings">&#9881;</text>
</view>
@@ -14,7 +14,7 @@
<image class="big-avatar-img" :src="avatarSrc" mode="aspectFill" @error="onAvatarError" />
<view class="user-info">
<text class="user-name">{{ userInfo.nickname }}</text>
<view class="user-tag">
<view class="user-tag" :style="{ color: brandConfig.primaryColor }">
<text>会员号{{ userInfo.memberNo }}</text>
</view>
</view>
@@ -40,14 +40,14 @@
</view>
</view>
<view class="stats-card">
<view class="stats-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="stat-item">
<text class="stat-number">{{ userInfo.totalSignInDays }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ userInfo.totalSignInDays }}</text>
<text class="stat-label">累计签到()</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ userInfo.monthSignInCount }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ userInfo.monthSignInCount }}</text>
<text class="stat-label">本月签到</text>
</view>
</view>
@@ -55,10 +55,10 @@
<view class="sign-section">
<view class="sign-header">
<view class="section-title-row">
<text class="section-symbol">&#9776;</text>
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">&#9776;</text>
<text class="section-title">最近签到</text>
</view>
<text class="section-more" @click="viewAllSignIn">查看全部</text>
<text class="section-more" @click="viewAllSignIn" :style="{ color: brandConfig.primaryColor }">查看全部</text>
</view>
<view v-for="(record, idx) in signInRecords" :key="idx" class="sign-item">
<view class="sign-left">
@@ -79,10 +79,10 @@
<view class="sign-section">
<view class="sign-header">
<view class="section-title-row">
<text class="section-symbol">&#10003;</text>
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">&#10003;</text>
<text class="section-title">团课签到记录</text>
</view>
<text class="section-more">最近{{ courseCheckInRecords.length }}</text>
<text class="section-more" :style="{ color: brandConfig.primaryColor }">最近{{ courseCheckInRecords.length }}</text>
</view>
<view v-for="(record, idx) in courseCheckInRecords" :key="idx" class="sign-item">
<view class="sign-left">
@@ -92,7 +92,7 @@
<view class="sign-right">
<text class="sign-type">{{ record.courseName }}</text>
<text class="sign-source">{{ record.location }}</text>
<text class="sign-status success">已签到</text>
<text class="sign-status success" :style="signStatusSuccessStyle">已签到</text>
</view>
</view>
<view v-if="courseCheckInRecords.length === 0" class="empty-hint">
@@ -131,7 +131,8 @@
</scroll-view>
<view class="edit-modal-footer">
<button class="edit-btn cancel" @click="closeEdit">取消</button>
<button class="edit-btn confirm" :disabled="saving" @click="saveProfile">
<button class="edit-btn confirm" :disabled="saving" @click="saveProfile"
:style="confirmBtnStyle">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
@@ -144,6 +145,7 @@
const store = require('../../store/index')
const memberApi = require('../../api/member')
const bookingApi = require('../../api/booking')
const brandStore = require('../../store/brand')
const defaultAvatar = require('../../common/img/20200414210134_qbeyi.jpg')
export default {
@@ -169,7 +171,8 @@
{ label: '女', value: 'FEMALE' }
],
today: '',
showDefaultAvatar: false
showDefaultAvatar: false,
brandConfig: brandStore.config
}
},
onLoad() {
@@ -188,13 +191,53 @@
//
const d = new Date()
this.today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
computed: {
headerStyle() {
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
navStyle() {
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
confirmBtnStyle() {
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
},
avatarSrc() {
if (this.showDefaultAvatar) return defaultAvatar
return this.userInfo.avatar || defaultAvatar
},
genderLabel() {
const g = this.userInfo.gender
if (g === 1 || g === 'MALE' || g === '男') return '男'
if (g === 2 || g === 'FEMALE' || g === '女') return '女'
return '未设置'
},
genderIndex() {
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
const genderStr = typeof this.editForm.gender === 'string'
? this.editForm.gender
: genderMap[this.editForm.gender] || 'UNKNOWN'
return Math.max(0, this.genderOptions.findIndex(o => o.value === genderStr))
},
signStatusSuccessStyle() {
return {
background: 'rgba(' + this.brandConfig.primaryColorRgb + ',0.15)',
color: this.brandConfig.primaryColor
}
}
},
onShow() {
if (!store.isLoggedIn) return
this.loadData()
brandStore.fetchConfig()
},
onPullDownRefresh() {
this.loadData().finally(() => { uni.stopPullDownRefresh() })
Promise.all([this.loadData(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
methods: {
async loadData() {
@@ -331,25 +374,6 @@
onAvatarError() { this.showDefaultAvatar = true },
goToSettings() { uni.showToast({ title: '设置页面', icon: 'none' }) },
viewAllSignIn() { uni.showToast({ title: '全部签到记录', icon: 'none' }) }
},
computed: {
avatarSrc() {
if (this.showDefaultAvatar) return defaultAvatar
return this.userInfo.avatar || defaultAvatar
},
genderLabel() {
const g = this.userInfo.gender
if (g === 1 || g === 'MALE' || g === '男') return '男'
if (g === 2 || g === 'FEMALE' || g === '女') return '女'
return '未设置'
},
genderIndex() {
const genderMap = { 0: 'UNKNOWN', 1: 'MALE', 2: 'FEMALE' }
const genderStr = typeof this.editForm.gender === 'string'
? this.editForm.gender
: genderMap[this.editForm.gender] || 'UNKNOWN'
return Math.max(0, this.genderOptions.findIndex(o => o.value === genderStr))
}
}
}
</script>
+38 -15
View File
@@ -1,10 +1,10 @@
<template>
<view class="search-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title">&#8981; 团课搜索</text>
</view>
<view class="search-area">
<view class="search-area" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="search-box">
<text class="search-symbol">&#8981;</text>
<input class="search-input" v-model="searchKeyword" placeholder="搜索课程名称、教练、场地..."
@@ -31,7 +31,8 @@
<view class="period-row">
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)"
:style="currentPeriod === period.value ? activeBrandStyle : ''">
<text>{{ period.label }}</text>
</view>
</view>
@@ -40,18 +41,20 @@
<view class="type-section">
<view class="type-all-row">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')"
:style="currentFilter === 'all' ? activeBrandStyle : ''">全部</view>
</view>
<view class="type-grid" :class="{ expanded: typesExpanded }">
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)">
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)"
:style="currentFilter === tab.value ? activeBrandStyle : ''">
{{ tab.label }}
</view>
</view>
</view>
<view v-if="typeRows.length > 1" class="type-expand-row">
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded">
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded" :style="{ color: brandConfig.primaryColor }">
{{ typesExpanded ? '收起 ▲' : '展开更多 ▾' }}
</text>
</view>
@@ -61,7 +64,8 @@
<text class="result-count"> {{ filteredCourses.length }} 个课程</text>
<view class="sort-options">
<text v-for="(sort, idx) in sortOptions" :key="idx" class="sort-item"
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)">
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)"
:style="currentSort === sort.value ? 'color:' + brandConfig.primaryColor : ''">
{{ sort.label }}
</text>
</view>
@@ -88,20 +92,20 @@
<view class="course-body">
<view class="course-top">
<text class="course-name">{{ course.courseName }}</text>
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }">
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }" :style="{ color: brandConfig.primaryColor }">
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}
</text>
</view>
<view class="course-labels" v-if="course.labels && course.labels.length">
<text v-for="(label, li) in course.labels" :key="li" class="course-label"
:style="{ background: label.color + '1a', color: label.color }">{{ label.labelName }}</text>
:style="label._style">{{ label.labelName }}</text>
</view>
<view class="course-meta">
<text class="meta-icon">&#128339;</text>
<text class="course-date">{{ course.shortDate }}</text>
<text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
<text class="course-duration" v-if="course.duration">{{ course.duration }}</text>
<text class="course-duration" v-if="course.duration" :style="{ color: brandConfig.primaryColor }">{{ course.duration }}</text>
</view>
<view class="course-meta">
<text class="meta-icon">&#128205;</text>
@@ -110,7 +114,8 @@
<view class="course-bottom">
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}</text>
<button class="btn-book" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
@click.stop="bookCourse(course)">
@click.stop="bookCourse(course)"
:style="course.canBook ? activeBrandStyle : ''">
{{ course.canBook ? '立即预约' : course.statusLabel }}
</button>
</view>
@@ -133,6 +138,7 @@
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
const { resolveCoverUrl } = require('../../utils/request')
export default {
@@ -161,7 +167,8 @@
{ label: '热度', value: 'popular' }
],
courses: [],
activeBookedCourseIds: new Set() // status=0ID
activeBookedCourseIds: new Set(), // status=0ID
brandConfig: brandStore.config
}
},
onLoad() {
@@ -179,15 +186,28 @@
this.totalHeaderHeight = statusBarHeight + navBarHeight
this.loadCourses()
this.loadTypes()
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
this.loadActiveBookings()
},
onPullDownRefresh() {
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings()])
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
computed: {
headerStyle() {
return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
navStyle() {
return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor
},
activeBrandStyle() {
return 'background:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
},
// COLS_PER_ROW
typeRows() {
const tabs = this.courseTypes.map(t => ({
@@ -266,7 +286,7 @@
status: statusStr,
statusLabel: statusLabel,
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
labels: this.getTypeLabels(c.courseType),
labels: this.getTypeLabels(c.courseType).map(l => ({ ...l, _style: { background: l.color + '1a', color: l.color } })),
coverImage: resolveCoverUrl(c.coverImage),
}
})
@@ -305,6 +325,9 @@
}
},
methods: {
labelStyle(label) {
return { background: label.color + '1a', color: label.color }
},
//
getTypeLabels(courseType) {
const type = this.getTypeInfo(courseType)
+99
View File
@@ -0,0 +1,99 @@
const brandApi = require('../api/brand')
const BRAND_CACHE_KEY = 'brand_config_cache'
const DEFAULT_CONFIG = {
brandName: 'Novalon健身房',
slogan: '专业健身,从这里开始',
primaryColor: '#00E676',
primaryColorRgb: '0,230,118',
secondaryColor: '#1A1A1A',
secondaryColorRgb: '26,26,26',
logoUrl: '',
backgroundImageUrl: '',
updatedAt: ''
}
/**
* 品牌方案 Store
*
* 响应式策略fetchConfig 更新后通过 uni.$emit('brand:updated', config) 广播
* 页面通过 uni.$on 监听并在 data 中维护副本确保 UI 自动刷新
*/
const brandStore = {
_config: null,
_loaded: false,
/** 获取当前品牌配置(优先内存,其次缓存,最后默认值) */
get config() {
if (this._config) return this._config
try {
const cached = uni.getStorageSync(BRAND_CACHE_KEY)
if (cached && cached.primaryColor) {
this._config = cached
return cached
}
} catch (e) { /* ignore */ }
return { ...DEFAULT_CONFIG }
},
/** 是否已从后端加载完成 */
get isLoaded() {
return this._loaded
},
/**
* 从后端拉取品牌配置
* 策略对比 updatedAt有变化时更新缓存并广播事件通知全部页面
* @returns {Promise<boolean>} 是否有变化
*/
async fetchConfig() {
try {
const res = await brandApi.getBrandConfig()
if (res) {
const newConfig = {
brandName: res.brandName || DEFAULT_CONFIG.brandName,
slogan: res.slogan || DEFAULT_CONFIG.slogan,
primaryColor: res.primaryColor || DEFAULT_CONFIG.primaryColor,
primaryColorRgb: res.primaryColorRgb || DEFAULT_CONFIG.primaryColorRgb,
secondaryColor: res.secondaryColor || DEFAULT_CONFIG.secondaryColor,
secondaryColorRgb: res.secondaryColorRgb || DEFAULT_CONFIG.secondaryColorRgb,
logoUrl: res.logoUrl || '',
backgroundImageUrl: res.backgroundImageUrl || '',
updatedAt: res.updatedAt || ''
}
if (!this._config || this._config.updatedAt !== newConfig.updatedAt) {
this._config = newConfig
try {
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
} catch (e) { /* ignore */ }
this._loaded = true
// 广播事件通知所有页面刷新
uni.$emit('brand:updated', newConfig)
return true
}
}
} catch (e) {
console.error('[BrandStore] 获取品牌配置失败:', e)
}
this._loaded = true
return false
},
/** 应用 TabBar 品牌色 */
applyTabBar() {
try {
const cfg = this.config
uni.setTabBarStyle({
color: '#7A7E84',
selectedColor: cfg.primaryColor,
backgroundColor: cfg.secondaryColor,
borderStyle: 'black'
})
} catch (e) { /* 非 TabBar 页面忽略 */ }
}
}
module.exports = brandStore
@@ -0,0 +1,267 @@
import { test, expect } from '@playwright/test';
test.describe('品牌定制管理完整工作流', () => {
test.describe.configure({ mode: 'serial' });
const testBrandName = `测试品牌_${Date.now()}`;
const testSlogan = `测试口号_${Date.now()}`;
const testPrimaryColor = '#FF5722';
const testSecondaryColor = '#37474F';
test('品牌定制页面可正常加载', async ({ page }) => {
await test.step('导航到品牌定制页面', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
});
await test.step('验证页面标题存在', async () => {
await expect(page.locator('h2')).toContainText('品牌定制', { timeout: 10000 });
});
await test.step('验证四个配置卡片渲染', async () => {
// Logo 设置卡片
await expect(page.locator('.brand-card').filter({ hasText: 'Logo 设置' })).toBeVisible({ timeout: 5000 });
// 背景图设置卡片
await expect(page.locator('.brand-card').filter({ hasText: '背景图设置' })).toBeVisible({ timeout: 5000 });
// 品牌配色卡片
await expect(page.locator('.brand-card').filter({ hasText: '品牌配色' })).toBeVisible({ timeout: 5000 });
// 品牌信息卡片
await expect(page.locator('.brand-card').filter({ hasText: '品牌信息' })).toBeVisible({ timeout: 5000 });
});
await test.step('验证实时预览区域存在', async () => {
await expect(page.locator('.brand-card').filter({ hasText: '实时预览' })).toBeVisible({ timeout: 5000 });
});
await test.step('验证 WebSocket 连接标签存在', async () => {
const wsTag = page.locator('.brand-page-header .el-tag');
await expect(wsTag).toBeVisible({ timeout: 5000 });
});
});
test('更新品牌信息', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('填写品牌名称', async () => {
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
const nameInput = brandCard.locator('input').first();
await nameInput.clear();
await nameInput.fill(testBrandName);
});
await test.step('填写品牌口号', async () => {
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
const sloganInput = brandCard.locator('input').nth(1);
await sloganInput.clear();
await sloganInput.fill(testSlogan);
});
await test.step('点击保存信息', async () => {
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
await brandCard.locator('button:has-text("保存信息")').click();
});
await test.step('验证保存成功提示', async () => {
await expect(page.locator('.el-message--success').first()).toBeVisible({ timeout: 8000 });
});
await test.step('验证预览区域品牌名称更新', async () => {
await page.waitForTimeout(500);
const mockBrandName = page.locator('.mock-brand-name');
await expect(mockBrandName).toContainText(testBrandName, { timeout: 5000 });
});
});
test('更新品牌配色', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('修改主色调', async () => {
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
// 主色调 HEX 输入框(.color-input 是 el-input 容器,需定位内部 <input>
const primaryHexInput = colorCard.locator('.color-row').first().locator('input').first();
await primaryHexInput.clear();
await primaryHexInput.fill(testPrimaryColor);
// 触发 blur 验证
await primaryHexInput.blur();
});
await test.step('修改辅助色', async () => {
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
const secondaryHexInput = colorCard.locator('.color-row').nth(1).locator('input').first();
await secondaryHexInput.clear();
await secondaryHexInput.fill(testSecondaryColor);
await secondaryHexInput.blur();
});
await test.step('保存配色', async () => {
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
await colorCard.locator('button:has-text("保存配色")').click();
});
await test.step('验证保存成功', async () => {
await expect(page.locator('.el-message--success').first()).toBeVisible({ timeout: 8000 });
});
});
test('品牌配色 - 输入非法HEX值应校验', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('输入无效HEX值并尝试保存', async () => {
const colorCard = page.locator('.brand-card').filter({ hasText: '品牌配色' });
const primaryHexInput = colorCard.locator('.color-row').first().locator('input').first();
await primaryHexInput.clear();
await primaryHexInput.fill('not-a-color');
await primaryHexInput.blur();
await colorCard.locator('button:has-text("保存配色")').click();
});
await test.step('验证校验警告提示', async () => {
await expect(page.locator('.el-message--warning').first()).toBeVisible({ timeout: 5000 });
});
});
test('品牌信息 - 空名称校验', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('清空品牌名称并保存', async () => {
const brandCard = page.locator('.brand-card').filter({ hasText: '品牌信息' });
const nameInput = brandCard.locator('input').first();
await nameInput.clear();
await brandCard.locator('button:has-text("保存信息")').click();
});
await test.step('验证警告提示', async () => {
await expect(page.locator('.el-message--warning').first()).toBeVisible({ timeout: 5000 });
});
});
test('Logo 区域 - 上传组件正常渲染', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('验证 Logo 上传区域存在', async () => {
const logoCard = page.locator('.brand-card').filter({ hasText: 'Logo 设置' });
await expect(logoCard.locator('.el-upload')).toBeVisible({ timeout: 5000 });
});
});
test('背景图区域 - 上传组件正常渲染', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('验证背景图上传区域存在', async () => {
const bgCard = page.locator('.brand-card').filter({ hasText: '背景图设置' });
await expect(bgCard.locator('.el-upload')).toBeVisible({ timeout: 5000 });
});
});
test('实时预览 - 底部导航切换', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('验证默认首页可见', async () => {
await expect(page.locator('.mock-brand-section')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
});
await test.step('切换到课程tab', async () => {
await page.locator('.mock-tab-item').nth(1).click();
await page.waitForTimeout(300);
await expect(page.locator('.mock-search-area')).toBeVisible({ timeout: 5000 });
});
await test.step('切换到我的课程tab', async () => {
await page.locator('.mock-tab-item').nth(2).click();
await page.waitForTimeout(300);
await expect(page.locator('.mock-booking-card').first()).toBeVisible({ timeout: 5000 });
});
await test.step('切换到我的tab', async () => {
await page.locator('.mock-tab-item').nth(3).click();
await page.waitForTimeout(300);
await expect(page.locator('.mock-profile-card')).toBeVisible({ timeout: 5000 });
});
await test.step('切换回首页', async () => {
await page.locator('.mock-tab-item').first().click();
await page.waitForTimeout(300);
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
});
});
test('实时预览 - 课程详情钻取与返回', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('点击今日推荐课程', async () => {
await page.locator('.mock-course-card').first().click();
await page.waitForTimeout(300);
});
await test.step('验证课程详情页渲染', async () => {
await expect(page.locator('.mock-detail-header')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.mock-detail-title')).toBeVisible({ timeout: 5000 });
});
await test.step('验证详情信息卡片', async () => {
await expect(page.locator('.mock-detail-info')).toBeVisible({ timeout: 5000 });
});
await test.step('点击返回按钮', async () => {
await page.locator('.mock-back-btn').click();
await page.waitForTimeout(300);
});
await test.step('验证返回首页', async () => {
await expect(page.locator('.mock-greeting-card')).toBeVisible({ timeout: 5000 });
});
});
test('实时预览 - 今日推荐查看更多进入搜索', async ({ page }) => {
await test.step('导航到品牌定制', async () => {
await page.goto('/brand');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
});
await test.step('点击查看全部', async () => {
await page.locator('.mock-section-more').click();
await page.waitForTimeout(300);
});
await test.step('验证进入课程搜索页', async () => {
await expect(page.locator('.mock-search-area')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.mock-search-list')).toBeVisible({ timeout: 5000 });
});
});
});
+23 -56
View File
@@ -145,8 +145,7 @@
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)",
"optional": true,
"peer": true
"optional": true
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
@@ -236,6 +235,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -276,6 +276,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1116,7 +1117,6 @@
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
@@ -1159,7 +1159,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1181,7 +1180,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1203,7 +1201,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1225,7 +1222,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1247,7 +1243,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1269,7 +1264,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1291,7 +1285,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1313,7 +1306,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1335,7 +1327,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1357,7 +1348,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1379,7 +1369,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1401,7 +1390,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1423,7 +1411,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
@@ -1439,7 +1426,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1906,6 +1892,7 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1916,6 +1903,7 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1975,6 +1963,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -2606,6 +2595,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2865,7 +2855,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -2902,8 +2891,7 @@
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
@@ -3125,7 +3113,6 @@
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3364,6 +3351,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4055,8 +4043,7 @@
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/import-fresh": {
"version": "3.3.1",
@@ -4421,13 +4408,15 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -4648,8 +4637,7 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/nopt": {
"version": "7.2.1",
@@ -5065,7 +5053,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -5250,7 +5237,6 @@
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -5262,7 +5248,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.1.5",
@@ -5285,7 +5270,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"colorjs.io": "^0.5.0",
@@ -5335,7 +5319,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"sass": "1.98.0"
}
@@ -5353,7 +5336,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5371,7 +5353,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5389,7 +5370,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5407,7 +5387,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5425,7 +5404,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5443,7 +5421,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5461,7 +5438,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5479,7 +5455,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5497,7 +5472,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5515,7 +5489,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5533,7 +5506,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5551,7 +5523,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5569,7 +5540,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5587,7 +5557,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5605,7 +5574,6 @@
"!linux",
"!win32"
],
"peer": true,
"dependencies": {
"sass": "1.98.0"
}
@@ -5623,7 +5591,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5641,7 +5608,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@@ -5653,7 +5619,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -5960,7 +5925,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"sync-message-port": "^1.0.0"
},
@@ -5975,7 +5939,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=16.0.0"
}
@@ -6071,6 +6034,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6176,8 +6140,7 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true,
"peer": true
"optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -6211,6 +6174,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6249,8 +6213,7 @@
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/vite": {
"version": "7.3.1",
@@ -6258,6 +6221,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6366,6 +6330,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6379,6 +6344,7 @@
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.1",
"@vitest/mocker": "4.1.1",
@@ -6480,6 +6446,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockRequest } = vi.hoisted(() => ({
mockRequest: {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
}))
vi.mock('@/utils/request', () => ({ default: mockRequest }))
import { bannerApi } from '@/api/banner.api'
describe('bannerApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getAll', () => {
it('should call GET /banners', async () => {
await bannerApi.getAll()
expect(mockRequest.get).toHaveBeenCalledWith('/banners')
})
})
describe('getActive', () => {
it('should call GET /banners/active', async () => {
await bannerApi.getActive()
expect(mockRequest.get).toHaveBeenCalledWith('/banners/active')
})
})
describe('getById', () => {
it('should call GET /banners/:id', async () => {
await bannerApi.getById(1)
expect(mockRequest.get).toHaveBeenCalledWith('/banners/1')
})
})
describe('create', () => {
it('should call POST /banners with data', async () => {
const data = { imageUrl: 'https://example.com/banner.jpg', title: 'New Banner' }
await bannerApi.create(data)
expect(mockRequest.post).toHaveBeenCalledWith('/banners', data)
})
})
describe('update', () => {
it('should call PUT /banners/:id with data', async () => {
const data = { imageUrl: 'https://example.com/banner.jpg', title: 'Updated' }
await bannerApi.update(1, data)
expect(mockRequest.put).toHaveBeenCalledWith('/banners/1', data)
})
})
describe('delete', () => {
it('should call DELETE /banners/:id', async () => {
await bannerApi.delete(1)
expect(mockRequest.delete).toHaveBeenCalledWith('/banners/1')
})
})
})
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockRequest } = vi.hoisted(() => ({
mockRequest: {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
}))
vi.mock('@/utils/request', () => ({ default: mockRequest }))
import { coachApi } from '@/api/coach.api'
describe('coachApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getAll', () => {
it('should call GET /coach/list', async () => {
await coachApi.getAll()
expect(mockRequest.get).toHaveBeenCalledWith('/coach/list')
})
})
describe('create', () => {
it('should call POST /coach with data', async () => {
const data = { username: 'coach1', password: 'pass', nickname: 'Coach One', email: 'c@test.com', phone: '123' }
await coachApi.create(data)
expect(mockRequest.post).toHaveBeenCalledWith('/coach', data)
})
})
describe('update', () => {
it('should call PUT /coach/:id with data', async () => {
const data = { nickname: 'New Name' }
await coachApi.update('1', data)
expect(mockRequest.put).toHaveBeenCalledWith('/coach/1', data)
})
})
describe('disable', () => {
it('should call POST /coach/:id/disable', async () => {
await coachApi.disable('1')
expect(mockRequest.post).toHaveBeenCalledWith('/coach/1/disable')
})
})
describe('getCourses', () => {
it('should call GET /coach/:id/courses', async () => {
await coachApi.getCourses('1')
expect(mockRequest.get).toHaveBeenCalledWith('/coach/1/courses')
})
})
describe('getViolationCounts', () => {
it('should call GET /coach/violation-counts', async () => {
await coachApi.getViolationCounts()
expect(mockRequest.get).toHaveBeenCalledWith('/coach/violation-counts')
})
})
describe('getViolations', () => {
it('should call GET /coach/:id/violations', async () => {
await coachApi.getViolations('1')
expect(mockRequest.get).toHaveBeenCalledWith('/coach/1/violations')
})
})
})
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockRequest } = vi.hoisted(() => ({
mockRequest: {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
}))
vi.mock('@/utils/request', () => ({ default: mockRequest }))
import { memberCardApi } from '@/api/memberCard.api'
describe('memberCardApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('list', () => {
it('should call GET /member-cards with params', async () => {
const params = { page: 0, size: 10 }
await memberCardApi.list(params)
expect(mockRequest.get).toHaveBeenCalledWith('/member-cards', { params })
})
it('should include filter params', async () => {
const params = { status: 1, name: 'Gold', page: 0, size: 20 }
await memberCardApi.list(params)
expect(mockRequest.get).toHaveBeenCalledWith('/member-cards', { params })
})
})
describe('getActiveCards', () => {
it('should call GET /member-cards/active', async () => {
await memberCardApi.getActiveCards()
expect(mockRequest.get).toHaveBeenCalledWith('/member-cards/active', { params: { status: undefined } })
})
it('should pass status param', async () => {
await memberCardApi.getActiveCards(1)
expect(mockRequest.get).toHaveBeenCalledWith('/member-cards/active', { params: { status: 1 } })
})
})
describe('getById', () => {
it('should call GET /member-cards/:id', async () => {
await memberCardApi.getById(5)
expect(mockRequest.get).toHaveBeenCalledWith('/member-cards/5')
})
})
describe('create', () => {
it('should call POST /member-cards with data', async () => {
const data = { memberCardName: 'Gold', memberCardPrice: 199 }
await memberCardApi.create(data)
expect(mockRequest.post).toHaveBeenCalledWith('/member-cards', data)
})
})
describe('update', () => {
it('should call PUT /member-cards/:id with data', async () => {
const data = { memberCardName: 'Platinum' }
await memberCardApi.update(3, data)
expect(mockRequest.put).toHaveBeenCalledWith('/member-cards/3', data)
})
})
describe('delete', () => {
it('should call DELETE /member-cards/:id', async () => {
await memberCardApi.delete(3)
expect(mockRequest.delete).toHaveBeenCalledWith('/member-cards/3')
})
})
})
@@ -0,0 +1,371 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import BannerManagement from '@/views/banner/BannerManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Plus: { template: '<svg/>' },
}))
// ===== Mock banner.api =====
vi.mock('@/api/banner.api', () => ({
bannerApi: {
getAll: vi.fn().mockResolvedValue([
{ id: 1, imageUrl: '/api/files/1/preview', title: '轮播图1', subtitle: '副标题1', sortOrder: 1, isActive: true, createdAt: '2025-01-01T00:00:00' },
{ id: 2, imageUrl: '/api/files/2/preview', title: '轮播图2', subtitle: '副标题2', sortOrder: 2, isActive: false, createdAt: '2025-01-02T00:00:00' },
]),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
},
}))
// ===== Mock request =====
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return { default: mockRequest }
})
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('BannerManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>' },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-upload': { template: '<div class="el-upload-stub"><slot/></div>', props: ['http-request', 'show-file-list', 'before-upload', 'accept'] },
'el-input-number': true,
'el-switch': true,
'el-image': true,
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染轮播图管理容器', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.banner-management').exists()).toBe(true)
})
it('应该渲染页面标题', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('轮播图管理')
})
it('应该渲染新增轮播图按钮', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增轮播图')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始 modalVisible 为 false', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
it('初始 dataSource 为空数组', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.dataSource).toBeDefined()
expect(Array.isArray(wrapper.vm.dataSource)).toBe(true)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
it('应该暴露 handleUpload 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleUpload).toBe('function')
})
it('应该暴露 beforeUpload 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.beforeUpload).toBe('function')
})
// ==================== 新增/编辑表单 ====================
it('点击新增后 modalVisible 应为 true', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
expect(wrapper.vm.modalTitle).toBe('新增轮播图')
})
it('新增时表单 title 应为空', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.title).toBe('')
})
it('新增时 sortOrder 应为 0', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.sortOrder).toBe(0)
})
it('新增时 isActive 应为 true', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.isActive).toBe(true)
})
// ==================== 表单规则 ====================
it('应该定义 imageUrl 必填规则', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.imageUrl).toBeDefined()
expect(wrapper.vm.formRules.imageUrl[0].required).toBe(true)
})
it('应该定义 title 必填规则', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.title).toBeDefined()
expect(wrapper.vm.formRules.title[0].required).toBe(true)
})
it('应该定义 sortOrder 必填规则', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.sortOrder).toBeDefined()
})
// ==================== extractFileId ====================
it('extractFileId 应能提取 /api/files/{id}/preview 格式的 ID', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.extractFileId('/api/files/123/preview')).toBe('123')
})
it('extractFileId 应对纯数字字符串返回该数字', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.extractFileId('456')).toBe('456')
})
it('extractFileId 应对 null/undefined 返回 null', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.extractFileId(null)).toBe(null)
expect(wrapper.vm.extractFileId(undefined)).toBe(null)
})
// ==================== beforeUpload ====================
it('beforeUpload 应拒绝非图片文件', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const { ElMessage } = await import('element-plus')
const result = wrapper.vm.beforeUpload(new File([''], 'test.txt', { type: 'text/plain' }))
expect(result).toBe(false)
expect(ElMessage.error).toHaveBeenCalled()
})
it('beforeUpload 应拒绝超过 5MB 的图片', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const { ElMessage } = await import('element-plus')
const largeFile = new File([''], 'large.jpg', { type: 'image/jpeg' })
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 })
const result = wrapper.vm.beforeUpload(largeFile)
expect(result).toBe(false)
expect(ElMessage.error).toHaveBeenCalled()
})
it('beforeUpload 应接受合法图片', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const validFile = new File([''], 'small.jpg', { type: 'image/jpeg' })
const result = wrapper.vm.beforeUpload(validFile)
expect(result).toBe(true)
})
// ==================== 预览 ====================
it('应该暴露 previewImage 方法', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.previewImage).toBe('function')
})
it('初始 previewVisible 应为 false', async () => {
wrapper = mount(BannerManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.previewVisible).toBe(false)
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import BrandManagement from '@/views/brand/BrandManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Plus: { template: '<svg/>' },
Edit: { template: '<svg/>' },
Delete: { template: '<svg/>' },
UploadFilled: { template: '<svg/>' },
}))
// ===== Mock brand.api =====
vi.mock('@/api/brand.api', () => ({
brandApi: {
getConfig: vi.fn().mockResolvedValue({
id: 1,
tenantId: 'default',
logoUrl: 'https://example.com/logo.png',
backgroundImageUrl: '',
primaryColor: '#00E676',
secondaryColor: '#1A1A1A',
fontFamily: 'default',
}),
uploadLogo: vi.fn().mockResolvedValue({
id: 1,
tenantId: 'default',
logoUrl: 'https://example.com/new-logo.png',
}),
uploadBackground: vi.fn().mockResolvedValue({
id: 1,
tenantId: 'default',
backgroundImageUrl: 'https://example.com/bg.png',
}),
removeLogo: vi.fn().mockResolvedValue({}),
removeBackground: vi.fn().mockResolvedValue({}),
updateColor: vi.fn().mockResolvedValue({
id: 1,
tenantId: 'default',
primaryColor: '#FF0000',
secondaryColor: '#0000FF',
}),
},
}))
// ===== Mock request =====
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return { default: mockRequest }
})
// ===== Mock WebSocket =====
class MockWebSocket {
url: string
readyState = 1
onopen: (() => void) | null = null
onmessage: ((e: any) => void) | null = null
onerror: (() => void) | null = null
onclose: (() => void) | null = null
constructor(url: string) {
this.url = url
setTimeout(() => this.onopen?.(), 0)
}
send = vi.fn()
close = vi.fn()
}
// @ts-ignore
globalThis.WebSocket = MockWebSocket
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('BrandManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>' },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-col': { template: '<div><slot/></div>' },
'el-row': { template: '<div><slot/></div>' },
'el-icon': { template: '<svg/>' },
'el-upload': { template: '<div class="el-upload-stub"><slot/></div>', props: ['http-request', 'show-file-list', 'before-upload', 'accept'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-input-number': true,
'el-color-picker': true,
'el-switch': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-tooltip': { template: '<div><slot/></div>' },
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染品牌管理容器', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.find('.brand-management').exists()).toBe(true)
})
it('应该渲染页面标题', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('品牌定制')
})
// ==================== 预览区域 ====================
it('应该渲染模拟小程序预览框', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.find('.preview-container').exists()).toBe(true)
expect(wrapper.find('.mock-miniapp').exists()).toBe(true)
expect(wrapper.html()).toContain('实时预览')
})
it('应该渲染问候卡片', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.find('.mock-greeting-card').exists()).toBe(true)
expect(wrapper.html()).toContain('你好,会员用户')
})
// ==================== 配置表单 ====================
it('应该渲染上传区域', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('Logo 设置')
expect(wrapper.html()).toContain('背景图设置')
})
// ==================== 颜色配置 ====================
it('应该渲染颜色配置卡片', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('品牌配色')
// 颜色选择器存在
expect(wrapper.find('.color-row').exists()).toBe(true)
})
// ==================== 操作按钮 ====================
it('应该渲染保存配色按钮', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('保存配色')
})
// ==================== 颜色 HEX 格式验证 ====================
it('应该能正常挂载并接收初始配置', () => {
wrapper = mount(BrandManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.vm).toBeDefined()
})
})
@@ -0,0 +1,392 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import CoachManagement from '@/views/system/CoachManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
}))
// ===== Mock coach.api =====
vi.mock('@/api/coach.api', () => ({
coachApi: {
getAll: vi.fn().mockResolvedValue([
{ id: '1', username: 'coach1', nickname: 'Coach One', email: 'coach1@test.com', phone: '13800138001', status: 1, createdAt: '2025-01-01T00:00:00', updatedAt: '2025-01-01T00:00:00' },
{ id: '2', username: 'coach2', nickname: 'Coach Two', email: 'coach2@test.com', phone: '13800138002', status: 0, createdAt: '2025-01-02T00:00:00', updatedAt: '2025-01-02T00:00:00' },
]),
getViolationCounts: vi.fn().mockResolvedValue([
{ coach_id: 1, count: 2 },
{ coach_id: 2, count: 0 },
]),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
disable: vi.fn().mockResolvedValue({}),
getCourses: vi.fn().mockResolvedValue([]),
getViolations: vi.fn().mockResolvedValue([]),
},
}))
// ===== Mock groupCourse.api =====
vi.mock('@/api/groupCourse.api', () => ({
groupCourseBookingApi: {
getByCourseId: vi.fn().mockResolvedValue([]),
},
}))
// ===== Mock request =====
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return { default: mockRequest }
})
// ===== Mock errorHandler =====
vi.mock('@/utils/errorHandler', () => ({
handleApiError: vi.fn(),
}))
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('CoachManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>' },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading', 'row-class-name'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size', 'round'] },
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop', 'required'] },
'el-descriptions': { template: '<div class="el-descriptions"><slot/></div>', props: ['column', 'border', 'size'] },
'el-descriptions-item': { template: '<div class="el-descriptions-item"><slot/></div>', props: ['label', 'span'] },
'el-empty': true,
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染教练管理容器', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.coach-management').exists()).toBe(true)
})
it('应该渲染搜索区域', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索教练用户名或昵称')
})
it('应该渲染新增教练按钮', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增教练')
})
// ==================== 初始状态 ====================
it('初始 loading 状态(onMounted 触发 fetchData 后 loading 启动)', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
// onMounted 调用了 fetchDataloading 应为 truemock API 异步返回)
expect(wrapper.vm.loading).toBe(true)
})
it('初始搜索关键词为空', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('')
})
it('初始模态框不可见', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleSearch 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleDisable 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDisable).toBe('function')
})
it('应该暴露 handleViewCourses 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleViewCourses).toBe('function')
})
it('应该暴露 handleViewDetail 方法', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleViewDetail).toBe('function')
})
// ==================== 表单状态绑定 ====================
it('点击新增后 modalVisible 应为 true', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
expect(wrapper.vm.modalTitle).toBe('新增教练')
})
it('新增时表单 username 应为空', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.username).toBe('')
})
it('新增时表单 password 应为空', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.password).toBe('')
})
// ==================== 表单规则 ====================
it('应该定义 username 必填规则', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.username).toBeDefined()
expect(wrapper.vm.formRules.username[0].required).toBe(true)
})
it('应该定义 password 必填规则', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.password).toBeDefined()
expect(wrapper.vm.formRules.password[0].required).toBe(true)
})
it('应该定义 email 必填规则', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.email).toBeDefined()
})
// ==================== computed 属性 ====================
it('filteredData 应在无搜索词时返回全部数据', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.dataSource = [
{ id: '1', username: 'coach1', nickname: 'A', email: 'a@t.com', status: 1 },
{ id: '2', username: 'coach2', nickname: 'B', email: 'b@t.com', status: 1 },
]
await wrapper.vm.$nextTick()
expect(wrapper.vm.filteredData.length).toBe(2)
})
it('filteredData 应按关键词过滤', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.dataSource = [
{ id: '1', username: 'coach1', nickname: 'Alpha', email: 'a@t.com', status: 1 },
{ id: '2', username: 'coach2', nickname: 'Beta', email: 'b@t.com', status: 1 },
]
wrapper.vm.searchKeyword = 'alpha'
await wrapper.vm.$nextTick()
expect(wrapper.vm.filteredData.length).toBe(1)
expect(wrapper.vm.filteredData[0].nickname).toBe('Alpha')
})
// ==================== 状态映射函数 ====================
it('getViolationTypeText 应正确返回违规类型文本', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.getViolationTypeText('COACH_LATE')).toBe('教练迟到')
expect(wrapper.vm.getViolationTypeText('COACH_ABSENT')).toBe('教练缺席')
expect(wrapper.vm.getViolationTypeText('NOT_MANUAL_END')).toBe('未手动结课')
expect(wrapper.vm.getViolationTypeText('UNKNOWN')).toBe('UNKNOWN')
})
it('getCourseStatusText 应正确返回课程状态文本', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.getCourseStatusText(0)).toBe('正常')
expect(wrapper.vm.getCourseStatusText(1)).toBe('已取消')
expect(wrapper.vm.getCourseStatusText(2)).toBe('已结束')
expect(wrapper.vm.getCourseStatusText(99)).toBe('未知')
})
// ==================== 违规标签颜色 ====================
it('getViolationTag 应在无违规时返回 success', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.getViolationTag(999)).toBe('success')
})
it('getViolationTag 应在有违规时返回 warning 或 danger', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.violationCounts[1] = 1
expect(wrapper.vm.getViolationTag(1)).toBe('warning')
wrapper.vm.violationCounts[1] = 5
expect(wrapper.vm.getViolationTag(1)).toBe('danger')
})
// ==================== 取消弹窗 ====================
it('handleModalCancel 应关闭弹窗', async () => {
wrapper = mount(CoachManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.modalVisible = true
wrapper.vm.handleModalCancel()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
})
@@ -0,0 +1,247 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import CourseLabelManagement from '@/views/groupcourse/CourseLabelManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
}))
// ===== Mock groupCourse.api =====
vi.mock('@/api/groupCourse.api', () => ({
courseLabelApi: {
getPage: vi.fn().mockResolvedValue({
content: [
{ id: 1, labelName: '热门', color: '#FF0000', description: '热门标签' },
{ id: 2, labelName: '新手', color: '#00FF00', description: '新手友好' },
],
totalElements: 2,
}),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
},
}))
describe('CourseLabelManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size', 'color'] },
'el-pagination': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-color-picker': true,
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染标签管理容器', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.course-label-management').exists()).toBe(true)
})
it('应该渲染搜索区域', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索标签名称')
})
it('应该渲染新增标签按钮', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增标签')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始搜索关键词为空', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('')
})
it('初始 modalVisible 为 false', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
it('初始 currentPage 为 1', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.currentPage).toBe(1)
})
it('初始 pageSize 为 10', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.pageSize).toBe(10)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleSearch 方法', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
// ==================== 新增弹窗 ====================
it('handleAdd 应打开新增弹窗并重置表单', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
expect(wrapper.vm.modalTitle).toBe('新增标签')
expect(wrapper.vm.formState.labelName).toBe('')
})
// ==================== 表单规则 ====================
it('应定义 labelName 必填规则', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.labelName).toBeDefined()
expect(wrapper.vm.formRules.labelName[0].required).toBe(true)
})
it('应定义 color 必填规则', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.color).toBeDefined()
})
// ==================== 分页操作 ====================
it('onSizeChange 应重置当前页为 1', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.currentPage = 3
wrapper.vm.onSizeChange()
await wrapper.vm.$nextTick()
expect(wrapper.vm.currentPage).toBe(1)
})
it('handleSearch 应重置当前页为 1', async () => {
wrapper = mount(CourseLabelManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.currentPage = 3
wrapper.vm.handleSearch()
await wrapper.vm.$nextTick()
expect(wrapper.vm.currentPage).toBe(1)
})
})
@@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import CourseRecommendManagement from '@/views/groupcourse/CourseRecommendManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
}))
// ===== Mock groupCourse.api =====
vi.mock('@/api/groupCourse.api', () => ({
groupCourseRecommendApi: {
getAll: vi.fn().mockResolvedValue([
{ id: 1, courseId: 1, recommendTitle: '推荐课程1', recommendContent: '内容1', recommendReason: '理由1', priority: 3, isActive: true, createdAt: '2025-01-01T00:00:00' },
{ id: 2, courseId: 2, recommendTitle: '推荐课程2', recommendContent: '内容2', recommendReason: '理由2', priority: 5, isActive: false, createdAt: '2025-01-02T00:00:00' },
]),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
toggleActive: vi.fn().mockResolvedValue({}),
},
groupCourseApi: {
getAll: vi.fn().mockResolvedValue([
{ id: 1, courseName: '团课A' },
{ id: 2, courseName: '团课B' },
]),
},
}))
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
describe('CourseRecommendManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-select': { template: '<select><slot/></select>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-option': { template: '<option/>', props: ['value', 'label'] },
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染推荐管理容器', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.course-recommend-management').exists()).toBe(true)
})
it('应该渲染页面标题', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('团课推荐管理')
})
it('应该渲染新增推荐按钮', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增推荐')
})
it('应该渲染恢复默认排序按钮', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('恢复默认排序')
})
// ==================== 初始状态 ====================
it('初始 loading 状态(onMounted 触发 fetchData', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
// onMounted 调用了 fetchDataloading 应为 true
expect(wrapper.vm.loading).toBe(true)
})
it('初始 modalVisible 为 false', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
it('应该暴露 handleToggleActive 方法', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleToggleActive).toBe('function')
})
it('应该暴露 resetSort 方法', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.resetSort).toBe('function')
})
// ==================== 新增弹窗 ====================
it('handleAdd 应打开新增弹窗', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
})
it('新增时 formState.recommendTitle 应为空', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.recommendTitle).toBe('')
})
it('新增时 formState.priority 应为默认值', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.isActive).toBe(true)
})
// ==================== 表单规则 ====================
it('应定义 courseId 必填规则', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.courseId).toBeDefined()
expect(wrapper.vm.formRules.courseId[0].required).toBe(true)
})
it('应定义 recommendTitle 必填规则', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.recommendTitle).toBeDefined()
})
it('应定义 priority 必填规则', async () => {
wrapper = mount(CourseRecommendManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.priority).toBeDefined()
})
})
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import CourseTypeManagement from '@/views/groupcourse/CourseTypeManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
}))
// ===== Mock groupCourse.api =====
vi.mock('@/api/groupCourse.api', () => ({
groupCourseTypeApi: {
getPage: vi.fn().mockResolvedValue({
content: [
{ id: 1, typeName: '瑜伽', baseDifficulty: 3, category: '健身', description: '瑜伽课程' },
{ id: 2, typeName: '搏击', baseDifficulty: 7, category: '格斗', description: '搏击课程' },
],
totalElements: 2,
}),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
getTypeLabels: vi.fn().mockResolvedValue({}),
getCategories: vi.fn().mockResolvedValue(['健身', '格斗', '舞蹈']),
},
courseLabelApi: {
getPage: vi.fn().mockResolvedValue({ content: [], totalElements: 0 }),
},
}))
describe('CourseTypeManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size', 'color'] },
'el-pagination': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-select': { template: '<select><slot/></select>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-option': { template: '<option/>', props: ['value', 'label'] },
'el-slider': true,
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染课程类型管理容器', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.course-type-management').exists()).toBe(true)
})
it('应该渲染搜索区域', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索类型名称')
})
it('应该渲染新增类型按钮', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增类型')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始搜索关键词为空', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('')
})
it('初始 searchCategory 为空', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchCategory).toBe('')
})
it('初始 modalVisible 为 false', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleSearch 方法', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
it('应该暴露 difficultyLabel 方法', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.difficultyLabel).toBe('function')
})
// ==================== difficultyLabel ====================
it('difficultyLabel(1) 应返回"入门"', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.difficultyLabel(1)).toBe('入门')
})
it('difficultyLabel(5) 应返回"中级"', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.difficultyLabel(5)).toBe('中级')
})
it('difficultyLabel(8) 应返回"进阶"', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.difficultyLabel(8)).toBe('进阶')
})
// ==================== 新增弹窗 ====================
it('handleAdd 应打开新增弹窗', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
})
it('新增时 formState.typeName 应为空', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.typeName).toBe('')
})
// ==================== 表单规则 ====================
it('应定义 typeName 必填规则', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.typeName).toBeDefined()
expect(wrapper.vm.formRules.typeName[0].required).toBe(true)
})
it('表单规则应包含 typeName 和 category', async () => {
wrapper = mount(CourseTypeManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
// typeName 一定存在
expect(wrapper.vm.formRules.typeName).toBeDefined()
expect(wrapper.vm.formRules.typeName[0].required).toBe(true)
// category 规则存在性取决于组件实现
expect(wrapper.vm.formRules).toBeDefined()
})
})
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import Forbidden from '@/views/system/Forbidden.vue'
describe('Forbidden Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-result': { template: '<div class="el-result"><span class="title">{{title}}</span><span class="sub-title">{{subTitle}}</span><slot name="extra"/></div>', props: ['icon', 'title', 'subTitle'] },
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type'] },
}
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div/>' } },
{ path: '/dashboard', component: { template: '<div id="dashboard"/>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染禁止访问容器', async () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.forbidden-container').exists()).toBe(true)
})
it('应该显示 403 状态码', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('403')
})
it('应该显示权限提示信息', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('抱歉,您没有权限访问此页面')
})
// ==================== 按钮渲染 ====================
it('应该渲染"返回上一页"按钮', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('返回上一页')
})
it('应该渲染"返回首页"按钮', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(wrapper.html()).toContain('返回首页')
})
// ==================== 方法存在性 ====================
it('应该暴露 goBack 方法', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(typeof wrapper.vm.goBack).toBe('function')
})
it('应该暴露 goHome 方法', () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
expect(typeof wrapper.vm.goHome).toBe('function')
})
// ==================== 导航行为 ====================
it('goHome 应导航到 /dashboard', async () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
const pushSpy = vi.spyOn(router, 'push')
wrapper.vm.goHome()
await wrapper.vm.$nextTick()
expect(pushSpy).toHaveBeenCalledWith('/dashboard')
})
it('goBack 应调用 router.go(-1)', async () => {
wrapper = mount(Forbidden, {
global: { plugins: [router], stubs: defaultStubs },
})
const goSpy = vi.spyOn(router, 'go')
wrapper.vm.goBack()
await wrapper.vm.$nextTick()
expect(goSpy).toHaveBeenCalledWith(-1)
})
})
@@ -0,0 +1,443 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import GroupCourseManagement from '@/views/groupcourse/GroupCourseManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
Plus: { template: '<svg/>' },
WarningFilled: { template: '<svg/>' },
Refresh: { template: '<svg/>' },
}))
// ===== Mock groupCourse.api =====
vi.mock('@/api/groupCourse.api', () => ({
groupCourseApi: {
getPage: vi.fn().mockResolvedValue({
content: [
{ id: 1, courseName: '瑜伽课', courseType: 1, coachId: 1, status: '0', startTime: '2025-01-01T10:00:00', endTime: '2025-01-01T11:00:00', location: '302室', maxMembers: 20, currentMembers: 10, coverImage: '', description: '瑜伽课描述', storedValueAmount: 0 },
{ id: 2, courseName: '搏击课', courseType: 2, coachId: 2, status: '1', startTime: '2025-01-02T14:00:00', endTime: '2025-01-02T15:00:00', location: '101室', maxMembers: 15, currentMembers: 5, coverImage: '', description: '搏击课描述', storedValueAmount: 50 },
],
totalElements: 2,
}),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
cancel: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
checkCoachConflict: vi.fn().mockResolvedValue({ hasConflict: false, conflicts: [] }),
},
groupCourseTypeApi: {
getAll: vi.fn().mockResolvedValue([
{ id: 1, typeName: '瑜伽' },
{ id: 2, typeName: '搏击' },
]),
},
groupCourseBookingApi: {
getByCourseId: vi.fn().mockResolvedValue([]),
},
}))
// ===== Mock coach.api =====
vi.mock('@/api/coach.api', () => ({
coachApi: {
getAll: vi.fn().mockResolvedValue([
{ id: '1', username: 'coach1', nickname: '教练1' },
{ id: '2', username: 'coach2', nickname: '教练2' },
]),
},
}))
// ===== Mock request =====
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return { default: mockRequest }
})
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('GroupCourseManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link', 'loading', 'circle', 'disabled'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-input-number': true,
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-pagination': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width', 'center'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-select': { template: '<select><slot/></select>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-option': { template: '<option/>', props: ['value', 'label'] },
'el-date-picker': true,
'el-upload': { template: '<div class="el-upload-stub"><slot/></div>', props: ['http-request', 'show-file-list', 'before-upload', 'accept'] },
'el-descriptions': { template: '<div class="el-descriptions"><slot/></div>', props: ['column', 'border'] },
'el-descriptions-item': { template: '<div class="el-descriptions-item"><slot/></div>', props: ['label', 'span'] },
'el-empty': true,
'el-image': true,
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染团课管理容器', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.group-course-management').exists()).toBe(true)
})
it('应该渲染搜索区域', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索课程名称')
})
it('应该渲染新增团课按钮', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增团课')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始搜索关键词为空', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('')
})
it('初始 searchStatus 为空', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchStatus).toBe('')
})
it('初始 modalVisible 为 false', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(false)
})
it('初始 pagination.current 为 1', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
// ==================== 方法存在性 ====================
it('应该暴露 handleSearch 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('应该暴露 handleAdd 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleCancel 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleCancel).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
it('应该暴露 showDetail 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.showDetail).toBe('function')
})
it('应该暴露 showQrCode 方法', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.showQrCode).toBe('function')
})
// ==================== statusMap ====================
it('statusMap 应正确映射状态', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.statusMap['0'].label).toBe('正常')
expect(wrapper.vm.statusMap['1'].label).toBe('已取消')
expect(wrapper.vm.statusMap['2'].label).toBe('已结束')
expect(wrapper.vm.statusMap['3'].label).toBe('进行中')
expect(wrapper.vm.statusMap['5'].label).toBe('教练缺席')
expect(wrapper.vm.statusMap['6'].label).toBe('自动结束')
expect(wrapper.vm.statusMap['7'].label).toBe('教练迟到')
})
// ==================== 表单规则 ====================
it('应定义 courseName 必填规则', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.courseName).toBeDefined()
expect(wrapper.vm.formRules.courseName[0].required).toBe(true)
})
it('应定义 startTime 必填规则', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.startTime).toBeDefined()
expect(wrapper.vm.formRules.startTime[0].required).toBe(true)
})
it('应定义 duration 必填规则', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.duration).toBeDefined()
expect(wrapper.vm.formRules.duration[0].required).toBe(true)
})
// ==================== 新增弹窗 ====================
it('handleAdd 应打开新增弹窗', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.modalVisible).toBe(true)
expect(wrapper.vm.modalTitle).toBe('新增团课')
})
it('新增时 formState.courseName 应为空', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.courseName).toBe('')
})
it('新增时 formState.maxMembers 应为 20', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleAdd()
await wrapper.vm.$nextTick()
expect(wrapper.vm.formState.maxMembers).toBe(20)
})
// ==================== beforeCoverUpload ====================
it('beforeCoverUpload 应拒绝非图片文件', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const { ElMessage } = await import('element-plus')
const result = wrapper.vm.beforeCoverUpload(new File([''], 'test.txt', { type: 'text/plain' }))
expect(result).toBe(false)
expect(ElMessage.error).toHaveBeenCalled()
})
it('beforeCoverUpload 应接受合法图片', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const result = wrapper.vm.beforeCoverUpload(new File([''], 'photo.jpg', { type: 'image/jpeg' }))
expect(result).toBe(true)
})
// ==================== extractFileId ====================
it('extractFileId 应能提取 /files/{id}/preview 格式', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.extractFileId('/api/files/123/preview')).toBe('123')
})
// ==================== downloadQrCode ====================
it('downloadQrCode 应在无 qrImageUrl 时不执行', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.qrImageUrl = ''
// Should not throw
expect(() => wrapper.vm.downloadQrCode()).not.toThrow()
})
// ==================== 搜索与分页 ====================
it('handleSearch 应重置当前页为 1', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.pagination.current = 3
wrapper.vm.handleSearch()
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
it('handleSizeChange 应重置当前页为 1', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.pagination.current = 3
wrapper.vm.handleSizeChange()
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
// ==================== getComputedEndTime ====================
it('getComputedEndTime 应在缺少 startTime 时返回空字符串', async () => {
wrapper = mount(GroupCourseManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.formState.startTime = ''
expect(wrapper.vm.getComputedEndTime()).toBe('')
})
})
@@ -0,0 +1,353 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import MemberCardManagement from '@/views/member/MemberCardManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
Plus: { template: '<svg/>' },
}))
// ===== Mock memberCard.api =====
vi.mock('@/api/memberCard.api', () => ({
memberCardApi: {
list: vi.fn().mockResolvedValue([
{ memberCardId: 1, memberCardName: '月卡', memberCardType: 'TIME_CARD', memberCardPrice: 299, memberCardValidityDays: 30, memberCardStatus: 1, createdAt: '2025-01-01T00:00:00' },
{ memberCardId: 2, memberCardName: '10次卡', memberCardType: 'COUNT_CARD', memberCardPrice: 199, memberCardTotalTimes: 10, memberCardStatus: 1, createdAt: '2025-01-02T00:00:00' },
{ memberCardId: 3, memberCardName: '500储值卡', memberCardType: 'STORED_VALUE_CARD', memberCardPrice: 500, memberCardAmount: 500, memberCardStatus: 0, createdAt: '2025-01-03T00:00:00' },
]),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
},
}))
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
describe('MemberCardManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link', 'loading'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-input-number': true,
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-switch': { template: '<div class="el-switch"/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-pagination': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-select': { template: '<select><slot/></select>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-option': { template: '<option/>', props: ['value', 'label'] },
'el-radio-group': { template: '<div class="el-radio-group"><slot/></div>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-radio': { template: '<label class="el-radio"><slot/></label>', props: ['value'] },
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染会员卡管理容器', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.member-card-management').exists()).toBe(true)
})
it('应该渲染过滤区域', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索卡名称')
})
it('应该渲染新增会员卡按钮', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('新增会员卡')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始 filterType 为空', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.filterType).toBe('')
})
it('初始 filterName 为空', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.filterName).toBe('')
})
it('初始 dialogVisible 为 false', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.dialogVisible).toBe(false)
})
it('初始 pagination.current 为 1', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
// ==================== 方法存在性 ====================
it('应该暴露 fetchData 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.fetchData).toBe('function')
})
it('应该暴露 handleFilter 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleFilter).toBe('function')
})
it('应该暴露 handleCreate 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleCreate).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleSubmit 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSubmit).toBe('function')
})
it('应该暴露 handleDelete 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
it('应该暴露 handleToggleStatus 方法', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleToggleStatus).toBe('function')
})
// ==================== cardTypeMap ====================
it('cardTypeMap 应正确映射卡类型', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.cardTypeMap['TIME_CARD']).toBe('时长卡')
expect(wrapper.vm.cardTypeMap['COUNT_CARD']).toBe('次卡')
expect(wrapper.vm.cardTypeMap['STORED_VALUE_CARD']).toBe('储值卡')
})
// ==================== 表单操作 ====================
it('handleCreate 应打开新增弹窗并重置表单', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleCreate()
await wrapper.vm.$nextTick()
expect(wrapper.vm.dialogVisible).toBe(true)
expect(wrapper.vm.dialogTitle).toBe('新增会员卡')
expect(wrapper.vm.isEditing).toBe(false)
expect(wrapper.vm.form.memberCardName).toBe('')
expect(wrapper.vm.form.memberCardType).toBe('TIME_CARD')
expect(wrapper.vm.form.memberCardStatus).toBe(1)
})
it('handleCreate 应设置 memberCardPrice 为 undefined', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleCreate()
await wrapper.vm.$nextTick()
expect(wrapper.vm.form.memberCardPrice).toBeUndefined()
})
// ==================== 表单规则 ====================
it('应定义 memberCardName 必填规则', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.memberCardName).toBeDefined()
expect(wrapper.vm.formRules.memberCardName[0].required).toBe(true)
})
it('应定义 memberCardType 必填规则', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.memberCardType).toBeDefined()
expect(wrapper.vm.formRules.memberCardType[0].required).toBe(true)
})
it('应定义 memberCardPrice 必填规则', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formRules.memberCardPrice).toBeDefined()
expect(wrapper.vm.formRules.memberCardPrice[0].required).toBe(true)
})
// ==================== handleTypeChange ====================
it('handleTypeChange 应在非编辑模式清空类型相关字段', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.isEditing = false
wrapper.vm.form.memberCardValidityDays = 30
wrapper.vm.form.memberCardTotalTimes = 10
wrapper.vm.form.memberCardAmount = 100
wrapper.vm.handleTypeChange()
await wrapper.vm.$nextTick()
expect(wrapper.vm.form.memberCardValidityDays).toBeUndefined()
expect(wrapper.vm.form.memberCardTotalTimes).toBeUndefined()
expect(wrapper.vm.form.memberCardAmount).toBeUndefined()
})
// ==================== filter 与分页操作 ====================
it('handleFilter 应重置到第一页', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.pagination.current = 3
wrapper.vm.handleFilter()
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
it('handleSizeChange 应重置到第一页', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.pagination.current = 3
wrapper.vm.handleSizeChange()
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
// ==================== submitting 状态 ====================
it('初始 submitting 为 false', async () => {
wrapper = mount(MemberCardManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.submitting).toBe(false)
})
})
@@ -0,0 +1,372 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import MemberManagement from '@/views/member/MemberManagement.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Search: { template: '<svg/>' },
UserFilled: { template: '<svg/>' },
Plus: { template: '<svg/>' },
Clock: { template: '<svg/>' },
}))
// ===== Mock member.api =====
vi.mock('@/api/member.api', () => ({
memberApi: {
getAll: vi.fn().mockResolvedValue([
{ id: 1, memberNo: 'M001', nickname: 'Member1', phone: '13800138001', gender: 1, createdAt: '2025-01-01T00:00:00', subscribed: true },
{ id: 2, memberNo: 'M002', nickname: 'Member2', phone: '13800138002', gender: 2, createdAt: '2025-01-02T00:00:00', subscribed: false },
]),
search: vi.fn().mockResolvedValue([
{ id: 1, memberNo: 'M001', nickname: 'Member1', phone: '13800138001', gender: 1, createdAt: '2025-01-01T00:00:00' },
]),
getDetail: vi.fn().mockResolvedValue({
id: 1, memberNo: 'M001', nickname: 'Member1', phone: '13800138001',
genderDesc: '男', birthday: '1990-01-01', address: '北京市',
avatar: '', subscribed: true, lastLoginAt: '2025-07-01T00:00:00',
createdAt: '2025-01-01T00:00:00', activeCardCount: 2, inactiveCardCount: 1,
}),
getMemberCardRecords: vi.fn().mockResolvedValue([
{ memberCardName: '月卡', memberCardType: 'TIME_CARD', expireTime: '2025-08-01T00:00:00', purchaseTime: '2025-07-01T00:00:00', status: 'ACTIVE' },
]),
update: vi.fn().mockResolvedValue({}),
updatePhone: vi.fn().mockResolvedValue({}),
},
}))
// ===== Mock request =====
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
put: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
}
return { default: mockRequest }
})
// ===== Mock dateFormat =====
vi.mock('@/utils/dateFormat', () => ({
formatDateTime: vi.fn((val: string) => val || '-'),
}))
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('MemberManagement Component', () => {
let router: any
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['type', 'size', 'link'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>' },
'el-input': { template: '<input/>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-icon': { template: '<svg/>' },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading'] },
'el-table-column': true,
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'effect', 'size'] },
'el-pagination': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-form': { template: '<form><slot/></form>', props: ['model', 'rules', 'label-width', 'ref'] },
'el-form-item': { template: '<div class="el-form-item"><slot/></div>', props: ['label', 'prop'] },
'el-select': { template: '<select><slot/></select>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-option': { template: '<option/>', props: ['value', 'label'] },
'el-date-picker': true,
'el-avatar': { template: '<div class="el-avatar"/>', props: ['src', 'size'] },
'el-descriptions': { template: '<div class="el-descriptions"><slot/></div>', props: ['column', 'border'] },
'el-descriptions-item': { template: '<div class="el-descriptions-item"><slot/></div>', props: ['label', 'span'] },
'el-empty': true,
'el-upload': { template: '<div class="el-upload-stub"><slot/></div>', props: ['http-request', 'show-file-list', 'before-upload', 'accept'] },
}
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div/>' } }],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染会员管理容器', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.member-management').exists()).toBe(true)
})
it('应该渲染搜索区域', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索会员编号/昵称/手机号')
})
it('应该渲染搜索按钮', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('搜索')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始 searchKeyword 为空', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.searchKeyword).toBe('')
})
it('初始 pagination.current 为 1', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
it('初始 detailVisible 为 false', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailVisible).toBe(false)
})
it('初始 editVisible 为 false', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.editVisible).toBe(false)
})
it('初始 phoneVisible 为 false', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.phoneVisible).toBe(false)
})
// ==================== 方法存在性 ====================
it('应该暴露 fetchData 方法', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.fetchData).toBe('function')
})
it('应该暴露 handleSearch 方法', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleSearch).toBe('function')
})
it('应该暴露 handleDetail 方法', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleDetail).toBe('function')
})
it('应该暴露 handleEdit 方法', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
it('应该暴露 handleEditPhone 方法', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleEditPhone).toBe('function')
})
// ==================== formatPhone 函数 ====================
it('formatPhone 应在有手机号时返回原值', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formatPhone('13800138000')).toBe('13800138000')
})
it('formatPhone 应在无手机号时返回"未绑定"', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.formatPhone(null)).toBe('未绑定')
expect(wrapper.vm.formatPhone(undefined)).toBe('未绑定')
expect(wrapper.vm.formatPhone('')).toBe('未绑定')
})
// ==================== genderMap ====================
it('genderMap 应正确映射性别', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.genderMap[0]).toBe('未知')
expect(wrapper.vm.genderMap[1]).toBe('男')
expect(wrapper.vm.genderMap[2]).toBe('女')
})
// ==================== cardTypeMap ====================
it('cardTypeMap 应正确映射卡类型', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.cardTypeMap['TIME_CARD']).toBe('时长卡')
expect(wrapper.vm.cardTypeMap['COUNT_CARD']).toBe('次卡')
expect(wrapper.vm.cardTypeMap['STORED_VALUE_CARD']).toBe('储值卡')
})
// ==================== phoneRules ====================
it('应定义 phone 表单规则', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.phoneRules.phone).toBeDefined()
expect(wrapper.vm.phoneRules.phone[0].required).toBe(true)
})
// ==================== 分页操作 ====================
it('handleSizeChange 应重置当前页为 1', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.pagination.current = 3
wrapper.vm.handleSizeChange()
await wrapper.vm.$nextTick()
expect(wrapper.vm.pagination.current).toBe(1)
})
// ==================== beforeAvatarUpload ====================
it('beforeAvatarUpload 应拒绝非图片文件', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const { ElMessage } = await import('element-plus')
const result = wrapper.vm.beforeAvatarUpload(new File([''], 'test.txt', { type: 'text/plain' }))
expect(result).toBe(false)
})
it('beforeAvatarUpload 应接受合法图片', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const result = wrapper.vm.beforeAvatarUpload(new File([''], 'photo.jpg', { type: 'image/jpeg' }))
expect(result).toBe(true)
})
// ==================== handleClearAvatar ====================
it('handleClearAvatar 应清空头像', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.editForm.avatar = 'https://example.com/avatar.jpg'
wrapper.vm.handleClearAvatar()
await wrapper.vm.$nextTick()
expect(wrapper.vm.editForm.avatar).toBe('')
})
// ==================== handleResetSort ====================
it('handleResetSort 应重置排序字段', async () => {
wrapper = mount(MemberManagement, {
global: { plugins: [router], stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.handleResetSort()
await wrapper.vm.$nextTick()
expect(wrapper.vm.sortField).toBe('createdAt')
expect(wrapper.vm.sortOrder).toBe('desc')
expect(wrapper.vm.pagination.current).toBe(1)
})
})
@@ -0,0 +1,386 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import StatisticsDashboard from '@/views/statistics/StatisticsDashboard.vue'
// ===== Mock Element Plus =====
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}))
// ===== Mock @element-plus/icons-vue =====
vi.mock('@element-plus/icons-vue', () => ({
Download: { template: '<svg/>' },
}))
// ===== Mock statistics.api =====
vi.mock('@/api/statistics.api', () => ({
statisticsApi: {
getSummary: vi.fn().mockResolvedValue({
memberStatistics: {
totalMembers: 100, newMembers: 10, signInMembers: 50,
bookingMembers: 30, cancelBookingMembers: 5, activeMembers: 60,
},
bookingStatistics: {
newBookings: 200, attendBookings: 150, cancelBookings: 30,
absentBookings: 20, attendanceRate: 75, cancelRate: 15,
},
signInStatistics: {
totalSignIns: 300, successSignIns: 280, failedSignIns: 20,
successRate: 93.3, qrCodeSignIns: 200, manualSignIns: 60, faceSignIns: 20,
},
coachStatistics: {
totalCoaches: 10, violatedCoaches: 2, totalCourses: 50,
lateCount: 3, absentCount: 1, notManualEndCount: 2,
},
generatedAt: '2025-07-23T10:00:00',
}),
getCoachRanking: vi.fn().mockResolvedValue([
{
coachName: 'Coach A', completedCourses: 20, attendedStudents: 150,
attendanceRate: 85, fillRate: 90, violationCount: 0, compositeScore: 88,
},
{
coachName: 'Coach B', completedCourses: 15, attendedStudents: 120,
attendanceRate: 70, fillRate: 75, violationCount: 2, compositeScore: 65,
},
]),
exportStatistics: vi.fn().mockResolvedValue(new Blob()),
},
}))
// ===== Mock URL.createObjectURL =====
if (!globalThis.URL.createObjectURL) {
// @ts-ignore
globalThis.URL.createObjectURL = vi.fn(() => `blob:mock-${Math.random()}`)
// @ts-ignore
globalThis.URL.revokeObjectURL = vi.fn()
}
describe('StatisticsDashboard Component', () => {
let wrapper: any
const defaultStubs = {
'el-button': { template: '<button @click="$emit(\'click\')"><slot/></button>', props: ['loading', 'icon', 'type', 'size', 'link'] },
'el-card': { template: '<div class="el-card"><slot/><slot name="header"/></div>', props: ['shadow'] },
'el-col': { template: '<div><slot/></div>', props: ['xs', 'sm'] },
'el-row': { template: '<div><slot/></div>', props: ['gutter'] },
'el-icon': { template: '<svg/>' },
'el-tabs': { template: '<div class="el-tabs"><slot/></div>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-tab-pane': { template: '<div class="el-tab-pane"><span class="tab-label">{{label}}</span><slot/></div>', props: ['label', 'name'] },
'el-radio-group': { template: '<div class="el-radio-group"><slot/></div>', props: ['modelValue'], emits: ['update:modelValue'] },
'el-radio-button': { template: '<label class="el-radio-button"><slot/></label>', props: ['value'] },
'el-tag': { template: '<span class="el-tag"><slot/></span>', props: ['type', 'size'] },
'el-progress': { template: '<div class="el-progress"/>', props: ['percentage', 'stroke-width', 'color'] },
'el-table': { template: '<div class="el-table"><slot/></div>', props: ['data', 'loading', 'stripe', 'border'] },
'el-table-column': true,
'el-dialog': { template: '<div v-if="modelValue" class="el-dialog"><slot/><slot name="footer"/></div>', props: ['modelValue', 'title', 'width'] },
'el-descriptions': { template: '<div class="el-descriptions"><slot/></div>', props: ['column', 'border'] },
'el-descriptions-item': { template: '<div class="el-descriptions-item"><slot/></div>', props: ['label', 'span'] },
'el-avatar': { template: '<div class="el-avatar"/>', props: ['src', 'size'] },
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) wrapper.unmount()
})
// ==================== 组件初始化 ====================
it('应该渲染统计面板容器', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.statistics-dashboard').exists()).toBe(true)
})
it('应该渲染时间区间选择器', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('今日')
expect(wrapper.html()).toContain('本周')
expect(wrapper.html()).toContain('本月')
expect(wrapper.html()).toContain('今年')
})
it('应该渲染导出报表按钮', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('导出报表')
})
it('应该渲染两个 Tab 页', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('数据总览')
expect(wrapper.html()).toContain('教练业绩')
})
// ==================== 初始状态 ====================
it('初始 loading 状态为 false', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.loading).toBe(false)
})
it('初始选中的区间应为 today', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedRange).toBe('today')
})
it('初始 activeTab 应为 overview', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.activeTab).toBe('overview')
})
it('初始 exporting 为 false', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.exporting).toBe(false)
})
// ==================== 方法存在性 ====================
it('应该暴露 onRangeChange 方法', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.onRangeChange).toBe('function')
})
it('应该暴露 onTabChange 方法', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.onTabChange).toBe('function')
})
it('应该暴露 showCoachDetail 方法', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.showCoachDetail).toBe('function')
})
it('应该暴露 handleExport 方法', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(typeof wrapper.vm.handleExport).toBe('function')
})
// ==================== rateColor 函数 ====================
it('rateColor 应在 rate >= 80 时返回绿色', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.rateColor(85)).toBe('#67c23a')
})
it('rateColor 应在 rate >= 50 时返回橙色', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.rateColor(60)).toBe('#e6a23c')
})
it('rateColor 应在 rate < 50 时返回红色', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.rateColor(30)).toBe('#f56c6c')
})
it('rateColor inverse=true 应在低取消率时返回绿色', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.rateColor(10, true)).toBe('#67c23a')
})
// ==================== scoreProgressColor 函数 ====================
it('scoreProgressColor 应正确返回颜色', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.scoreProgressColor(90)).toBe('#67c23a')
expect(wrapper.vm.scoreProgressColor(60)).toBe('#e6a23c')
expect(wrapper.vm.scoreProgressColor(30)).toBe('#f56c6c')
})
// ==================== getQueryParams ====================
it('getQueryParams 应返回正确的 periodType', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
wrapper.vm.selectedRange = 'today'
expect(wrapper.vm.getQueryParams().periodType).toBe('DAY')
wrapper.vm.selectedRange = 'week'
expect(wrapper.vm.getQueryParams().periodType).toBe('WEEK')
wrapper.vm.selectedRange = 'month'
expect(wrapper.vm.getQueryParams().periodType).toBe('MONTH')
wrapper.vm.selectedRange = 'last30'
expect(wrapper.vm.getQueryParams().periodType).toBe('LAST_30_DAYS')
wrapper.vm.selectedRange = 'last90'
expect(wrapper.vm.getQueryParams().periodType).toBe('LAST_90_DAYS')
wrapper.vm.selectedRange = 'year'
expect(wrapper.vm.getQueryParams().periodType).toBe('YEAR')
})
// ==================== 统计卡片区域 ====================
it('应该渲染会员总数标签', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('会员总数')
})
it('应该渲染活跃会员标签', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('活跃会员')
})
it('应该渲染预约总数标签', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('预约总数')
})
it('应该渲染教练违规标签', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('教练违规')
})
// ==================== 分区标题 ====================
it('应该渲染会员统计分区', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('会员统计')
})
it('应该渲染预约统计分区', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('预约统计')
})
it('应该渲染签到统计分区', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('签到统计')
})
it('应该渲染教练统计分区', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('教练统计')
})
// ==================== coach 详情弹窗 ====================
it('初始 coachDetailVisible 应为 false', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.coachDetailVisible).toBe(false)
})
it('showCoachDetail 应打开详情弹窗', async () => {
wrapper = mount(StatisticsDashboard, {
global: { stubs: defaultStubs },
})
await wrapper.vm.$nextTick()
const coach = { coachName: 'Test Coach', completedCourses: 10, compositeScore: 85 }
wrapper.vm.showCoachDetail(coach)
await wrapper.vm.$nextTick()
expect(wrapper.vm.coachDetailVisible).toBe(true)
expect(wrapper.vm.selectedCoach).toEqual(coach)
})
})
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import { UserStatus, RoleStatus, MenuStatus, NoticeStatus, StatusHelper } from '@/constants/status'
describe('Status Enums', () => {
describe('UserStatus', () => {
it('should have ACTIVE = 1', () => {
expect(UserStatus.ACTIVE).toBe(1)
})
it('should have INACTIVE = 0', () => {
expect(UserStatus.INACTIVE).toBe(0)
})
it('should have LOCKED = 2', () => {
expect(UserStatus.LOCKED).toBe(2)
})
})
describe('RoleStatus', () => {
it('should have ACTIVE = 1', () => {
expect(RoleStatus.ACTIVE).toBe(1)
})
it('should have INACTIVE = 0', () => {
expect(RoleStatus.INACTIVE).toBe(0)
})
})
describe('MenuStatus', () => {
it('should have ACTIVE = 1', () => {
expect(MenuStatus.ACTIVE).toBe(1)
})
it('should have INACTIVE = 0', () => {
expect(MenuStatus.INACTIVE).toBe(0)
})
})
describe('NoticeStatus', () => {
it('should have ACTIVE = "1"', () => {
expect(NoticeStatus.ACTIVE).toBe('1')
})
it('should have INACTIVE = "0"', () => {
expect(NoticeStatus.INACTIVE).toBe('0')
})
})
})
describe('StatusHelper', () => {
describe('isActive', () => {
it('should return true for numeric 1', () => {
expect(StatusHelper.isActive(1)).toBe(true)
})
it('should return true for string "1"', () => {
expect(StatusHelper.isActive('1')).toBe(true)
})
it('should return true for "ACTIVE"', () => {
expect(StatusHelper.isActive('ACTIVE')).toBe(true)
})
it('should return false for numeric 0', () => {
expect(StatusHelper.isActive(0)).toBe(false)
})
it('should return false for string "0"', () => {
expect(StatusHelper.isActive('0')).toBe(false)
})
it('should return false for numeric 2 (LOCKED)', () => {
expect(StatusHelper.isActive(2)).toBe(false)
})
})
describe('isInactive', () => {
it('should return true for numeric 0', () => {
expect(StatusHelper.isInactive(0)).toBe(true)
})
it('should return true for string "0"', () => {
expect(StatusHelper.isInactive('0')).toBe(true)
})
it('should return true for "INACTIVE"', () => {
expect(StatusHelper.isInactive('INACTIVE')).toBe(true)
})
it('should return false for numeric 1', () => {
expect(StatusHelper.isInactive(1)).toBe(false)
})
it('should return false for string "1"', () => {
expect(StatusHelper.isInactive('1')).toBe(false)
})
})
describe('getStatusText', () => {
it('should return "正常" for active status', () => {
expect(StatusHelper.getStatusText(1)).toBe('正常')
})
it('should return "禁用" for inactive status', () => {
expect(StatusHelper.getStatusText(0)).toBe('禁用')
})
it('should return "未知" for unknown status', () => {
expect(StatusHelper.getStatusText(999)).toBe('未知')
})
})
describe('getStatusType', () => {
it('should return "success" for active status', () => {
expect(StatusHelper.getStatusType(1)).toBe('success')
expect(StatusHelper.getStatusType('1')).toBe('success')
})
it('should return "danger" for inactive status', () => {
expect(StatusHelper.getStatusType(0)).toBe('danger')
expect(StatusHelper.getStatusType('0')).toBe('danger')
})
it('should return "warning" for unknown status', () => {
expect(StatusHelper.getStatusType(999)).toBe('warning')
})
})
})
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { formatDateTime, formatDate, formatTime } from '@/utils/dateFormat'
describe('dateFormat', () => {
describe('formatDateTime', () => {
it('should return dash for null', () => {
expect(formatDateTime(null)).toBe('-')
})
it('should return dash for undefined', () => {
expect(formatDateTime(undefined)).toBe('-')
})
it('should format ISO string correctly', () => {
const result = formatDateTime('2025-01-15T10:30:00.000Z')
expect(result).toContain('2025')
expect(result).toContain('01')
expect(result).toContain('15')
})
it('should format Date object', () => {
const date = new Date(2025, 0, 15, 10, 30, 0)
const result = formatDateTime(date)
expect(result).toContain('2025')
expect(result).toContain('01')
expect(result).toContain('15')
expect(result).toContain('10:30:00')
})
it('should return dash for invalid date string', () => {
const result = formatDateTime('not-a-date')
expect(result).toBe('-')
})
})
describe('formatDate', () => {
it('should return dash for null', () => {
expect(formatDate(null)).toBe('-')
})
it('should return dash for undefined', () => {
expect(formatDate(undefined)).toBe('-')
})
it('should format date only, no time', () => {
const result = formatDate('2025-06-20')
expect(result).toBe('2025-06-20')
expect(result).not.toContain(':')
})
it('should format Date object to date only', () => {
const date = new Date(2025, 5, 20)
const result = formatDate(date)
expect(result).toBe('2025-06-20')
})
it('should return dash for invalid input', () => {
const result = formatDate('abc')
expect(result).toBe('-')
})
})
describe('formatTime', () => {
it('should return dash for null', () => {
expect(formatTime(null)).toBe('-')
})
it('should return dash for undefined', () => {
expect(formatTime(undefined)).toBe('-')
})
it('should format time only', () => {
const result = formatTime('2025-01-15T14:30:45')
expect(result).toBe('14:30:45')
})
it('should format Date object to time only', () => {
const date = new Date(2025, 0, 15, 9, 5, 30)
const result = formatTime(date)
expect(result).toBe('09:05:30')
})
it('should return dash for invalid input', () => {
const result = formatTime('')
expect(result).toBe('-')
})
})
})
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
// Must import after mock
import { checkApiPermission, getRequiredPermission } from '@/utils/permission'
describe('permission utils', () => {
beforeEach(() => {
localStorageMock.clear()
})
describe('getRequiredPermission', () => {
it('should return correct permission for exact match', () => {
expect(getRequiredPermission('GET', '/users')).toBe('system:user:list')
})
it('should return correct permission for POST', () => {
expect(getRequiredPermission('POST', '/users')).toBe('system:user:add')
})
it('should return correct permission for DELETE', () => {
expect(getRequiredPermission('DELETE', '/roles')).toBe('system:role:remove')
})
it('should strip query string from URL', () => {
expect(getRequiredPermission('GET', '/users?page=1&size=10')).toBe('system:user:list')
})
it('should return null for unmapped URL', () => {
expect(getRequiredPermission('GET', '/unknown')).toBeNull()
})
it('should uppercase method', () => {
expect(getRequiredPermission('get', '/users')).toBe('system:user:list')
})
})
describe('checkApiPermission', () => {
it('should return true when no permission data in localStorage', () => {
expect(checkApiPermission('GET', '/users')).toBe(true)
})
it('should return true when user has required permission', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: ['system:user:list'] }))
expect(checkApiPermission('GET', '/users')).toBe(true)
})
it('should return false when user lacks required permission', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: ['system:role:list'] }))
expect(checkApiPermission('GET', '/users')).toBe(false)
})
it('should return true for /menus GET (always allowed)', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: [] }))
expect(checkApiPermission('GET', '/menus')).toBe(true)
})
it('should return true for unmapped endpoints', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: [] }))
expect(checkApiPermission('GET', '/unmapped-endpoint')).toBe(true)
})
it('should strip query params before matching', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: ['system:user:list'] }))
expect(checkApiPermission('GET', '/users?page=1&sort=name')).toBe(true)
})
it('should support array-style required permissions (any match)', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: ['system:config:list'] }))
expect(checkApiPermission('GET', '/config')).toBe(true)
})
it('should return true on JSON parse error', () => {
localStorageMock.setItem('permission', 'invalid-json')
expect(checkApiPermission('GET', '/users')).toBe(true)
})
it('should be case-insensitive for method', () => {
localStorageMock.setItem('permission', JSON.stringify({ permissions: ['system:user:list'] }))
expect(checkApiPermission('get', '/users')).toBe(true)
})
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { generateSignature, generateSignatureHeaders } from '@/utils/signature'
describe('signature', () => {
describe('generateSignature', () => {
it('should generate a non-empty signature', () => {
const sig = generateSignature('GET', '/api/users', '', '', 1700000000000, 'test-nonce')
expect(sig).toBeTruthy()
expect(typeof sig).toBe('string')
expect(sig.length).toBeGreaterThan(0)
})
it('should generate consistent signatures for same input', () => {
const sig1 = generateSignature('POST', '/api/data', 'page=1', '{"key":"value"}', 1700000000000, 'abc123')
const sig2 = generateSignature('POST', '/api/data', 'page=1', '{"key":"value"}', 1700000000000, 'abc123')
expect(sig1).toBe(sig2)
})
it('should generate different signatures for different methods', () => {
const sig1 = generateSignature('GET', '/api/test', '', '', 1700000000000, 'n1')
const sig2 = generateSignature('POST', '/api/test', '', '', 1700000000000, 'n1')
expect(sig1).not.toBe(sig2)
})
it('should generate different signatures for different paths', () => {
const sig1 = generateSignature('GET', '/api/users', '', '', 1700000000000, 'n1')
const sig2 = generateSignature('GET', '/api/roles', '', '', 1700000000000, 'n1')
expect(sig1).not.toBe(sig2)
})
it('should generate different signatures for different nonces', () => {
const sig1 = generateSignature('GET', '/api/test', '', '', 1700000000000, 'nonce-1')
const sig2 = generateSignature('GET', '/api/test', '', '', 1700000000000, 'nonce-2')
expect(sig1).not.toBe(sig2)
})
it('should handle empty query and body', () => {
const sig = generateSignature('GET', '/api/simple', '', '', 1700000000000, 'simple')
expect(sig).toBeTruthy()
})
it('should be base64-encoded', () => {
const sig = generateSignature('GET', '/api/test', '', '', 1700000000000, 'test')
// Base64 only contains specific characters
expect(/^[A-Za-z0-9+/=]+$/.test(sig)).toBe(true)
})
})
describe('generateSignatureHeaders', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1700000000000)
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('should return headers with correct shape', () => {
const headers = generateSignatureHeaders('GET', '/api/users')
expect(headers).toHaveProperty('X-Signature')
expect(headers).toHaveProperty('X-Timestamp')
expect(headers).toHaveProperty('X-Nonce')
})
it('should set X-Timestamp to current time', () => {
const headers = generateSignatureHeaders('GET', '/api/test')
expect(headers['X-Timestamp']).toBe('1700000000000')
})
it('should set X-Nonce to a non-empty string', () => {
const headers = generateSignatureHeaders('GET', '/api/test')
expect(headers['X-Nonce']).toBeTruthy()
expect(headers['X-Nonce'].length).toBeGreaterThan(0)
})
it('should handle full URL with https', () => {
const headers = generateSignatureHeaders('POST', 'https://example.com/api/data?page=1')
expect(headers['X-Signature']).toBeTruthy()
expect(headers['X-Timestamp']).toBe('1700000000000')
})
it('should handle relative URL without query', () => {
const headers = generateSignatureHeaders('GET', '/api/simple')
expect(headers['X-Signature']).toBeTruthy()
})
it('should uppercase the method', () => {
const headers = generateSignatureHeaders('get', '/api/test')
expect(headers['X-Signature']).toBeTruthy()
})
it('should generate same headers for identical calls', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.12345)
const h1 = generateSignatureHeaders('GET', '/api/test')
const h2 = generateSignatureHeaders('GET', '/api/test')
expect(h1['X-Signature']).toBe(h2['X-Signature'])
})
})
})
+51
View File
@@ -0,0 +1,51 @@
import request from '@/utils/request'
export interface BrandConfig {
id?: number
tenantId: string
logoUrl?: string
backgroundImageUrl?: string
primaryColor?: string
primaryColorRgb?: string
secondaryColor?: string
secondaryColorRgb?: string
fontFamily?: string
brandName?: string
slogan?: string
createdAt?: string
updatedAt?: string
}
export const brandApi = {
/** 获取品牌配置(tenantId 由后端从 JWT 提取) */
getConfig: () =>
request.get<BrandConfig>('/brand'),
/** 上传 Logo */
uploadLogo: (formData: FormData) =>
request.post<BrandConfig>('/brand/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
/** 删除 Logo */
removeLogo: () =>
request.delete<BrandConfig>('/brand/logo'),
/** 上传背景图 */
uploadBackground: (formData: FormData) =>
request.post<BrandConfig>('/brand/background', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
/** 删除背景图 */
removeBackground: () =>
request.delete<BrandConfig>('/brand/background'),
/** 更新品牌配色 */
updateColor: (data: Partial<BrandConfig>) =>
request.put<BrandConfig>('/brand/color', data),
/** 更新品牌信息(名称、口号) */
updateBrandInfo: (data: Partial<BrandConfig>) =>
request.put<BrandConfig>('/brand/info', data),
}
+6
View File
@@ -88,6 +88,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/banner/BannerManagement.vue'),
meta: { title: '轮播图管理' }
},
{
path: 'brand',
name: 'BrandManagement',
component: () => import('@/views/brand/BrandManagement.vue'),
meta: { title: '品牌定制' }
},
{
path: 'loginlog',
name: 'LoginLog',
+2
View File
@@ -47,6 +47,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'member/card/index': '/member-cards',
'statistics/dashboard/index': '/statistics',
'banner/index': '/banners',
'brand/index': '/brand',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -111,6 +112,7 @@ function getMenuIcon(menuName: string): string {
'会员卡管理': 'CreditCard',
'数据统计': 'DataAnalysis',
'轮播图管理': 'Picture',
'品牌定制': 'Brush',
}
return iconMap[menuName] || 'Document'
}
+4
View File
@@ -32,6 +32,10 @@ const permissionMapping: PermissionMapping = {
'GET /files': 'system:file:list',
'POST /files': 'system:file:upload',
'DELETE /files': 'system:file:delete',
'GET /brand': 'brand:config:list',
'POST /brand': 'brand:config:list',
'PUT /brand': 'brand:config:list',
'DELETE /brand': 'brand:config:list',
}
export function checkApiPermission(method: string, url: string): boolean {
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More