Compare commits
2 Commits
4c07ec5455
...
86b7555943
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b7555943 | |||
| b689656faf |
+175
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
+105
@@ -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();
|
||||
}
|
||||
}
|
||||
+56
@@ -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; }
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+172
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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;
|
||||
}
|
||||
}
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+161
@@ -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("."));
|
||||
}
|
||||
}
|
||||
+206
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
cn.novalon.gym.manage.brand.config.BrandWebSocketConfig
|
||||
+296
@@ -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;
|
||||
}
|
||||
}
|
||||
+170
@@ -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");
|
||||
}
|
||||
}
|
||||
+182
@@ -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();
|
||||
}
|
||||
}
|
||||
+209
@@ -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;
|
||||
}
|
||||
}
|
||||
+181
@@ -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;
|
||||
}
|
||||
}
|
||||
+172
@@ -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;
|
||||
}
|
||||
}
|
||||
+176
@@ -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);
|
||||
}
|
||||
}
|
||||
+219
@@ -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();
|
||||
}
|
||||
}
|
||||
+227
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+14
-1
@@ -20,6 +20,7 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler;
|
||||
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
|
||||
import cn.novalon.gym.manage.notify.handler.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+88
@@ -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; }
|
||||
}
|
||||
+38
@@ -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));
|
||||
+12
@@ -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 '品牌口号';
|
||||
+41
-4
@@ -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 = "删除指定文件")
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+2
@@ -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());
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+170
@@ -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;
|
||||
}
|
||||
}
|
||||
+195
@@ -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;
|
||||
}
|
||||
}
|
||||
+219
@@ -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;
|
||||
}
|
||||
}
|
||||
+228
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 API(HMAC-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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤3:Banner & 系统通知
|
||||
// ════════════════════════════════════════════════════
|
||||
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">←</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">←</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) {
|
||||
|
||||
@@ -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">☰</text>
|
||||
<view class="entry-icon-wrap green-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
|
||||
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">☰</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">☺</text>
|
||||
<view class="entry-icon-wrap dark-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.secondaryColorRgb + ',0.1)' }">
|
||||
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">☺</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() {
|
||||
|
||||
@@ -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">←</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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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 API(HMAC-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 API(HMAC-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 为空(使用默认值)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 步骤2:Banner & 系统通知
|
||||
// ════════════════════════════════════════════════════
|
||||
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">‹</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) {
|
||||
|
||||
@@ -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">✯ 今日训练</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">✓</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">✯ 今日推荐</text>
|
||||
<text class="section-more" @click="goToSearch">查看全部 ›</text>
|
||||
<text class="section-more" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">查看全部 ›</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; }
|
||||
|
||||
@@ -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">✓</text>
|
||||
<view class="checkbox" :class="{ checked: agreed }" :style="agreed ? agreedStyle : ''">
|
||||
<text v-if="agreed" class="check-mark" :style="{ color: brandConfig.secondaryColor }">✓</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">⌕ 我的课程</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -53,7 +53,7 @@
|
||||
<view v-if="!loading && bookings.length === 0" class="empty-state">
|
||||
<text class="empty-symbol">⊙</text>
|
||||
<text class="empty-text">暂无预约课程</text>
|
||||
<text class="empty-sub" @click="goToSearch">前往预约团课 ›</text>
|
||||
<text class="empty-sub" :style="{ color: brandConfig.primaryColor }" @click="goToSearch">前往预约团课 ›</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' }) },
|
||||
|
||||
@@ -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">● 个人中心</text>
|
||||
<text class="settings-symbol" @click="goToSettings">⚙</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">☰</text>
|
||||
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">☰</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">✓</text>
|
||||
<text class="section-symbol" :style="{ color: brandConfig.primaryColor }">✓</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>
|
||||
|
||||
@@ -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">⌕ 团课搜索</text>
|
||||
</view>
|
||||
<view class="search-area">
|
||||
<view class="search-area" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||
<view class="search-box">
|
||||
<text class="search-symbol">⌕</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">🕓</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">📍</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=0)的课程ID集合
|
||||
activeBookedCourseIds: new Set(), // 用户当前已预约(status=0)的课程ID集合
|
||||
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)
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+23
-56
@@ -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 调用了 fetchData,loading 应为 true(mock 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 调用了 fetchData,loading 应为 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user