增加品牌方案管理
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = "删除指定文件")
|
||||
|
||||
+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);
|
||||
|
||||
+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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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",
|
||||
|
||||
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,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,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
@@ -15,9 +15,15 @@ export default defineConfig({
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_TARGET || 'http://localhost:8080',
|
||||
target: process.env.VITE_API_TARGET || 'http://localhost:8084',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
},
|
||||
'/ws': {
|
||||
target: process.env.VITE_API_TARGET || 'http://localhost:8084',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false
|
||||
}
|
||||
},
|
||||
hmr: {
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# 品牌方案 — 小程序端更新说明
|
||||
|
||||
## 概述
|
||||
|
||||
后台管理系统配置的品牌方案(Logo、主色调、辅助色、背景图、品牌名、口号)通过 REST API 实时同步至会员端和教练端 UniApp 小程序。小程序冷启动、页面显示、下拉刷新时自动拉取最新配置。
|
||||
|
||||
---
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ 后台管理端 (web) │ │ 小程序端 (uniapp) │
|
||||
│ │ │ │
|
||||
│ BrandManagement.vue │ │ App.vue onLaunch │
|
||||
│ 保存品牌配置 ──────────┐ │ │ │
|
||||
│ │ │ │ brandStore │
|
||||
│ POST /api/brand/logo│ │ │ .fetchConfig() │
|
||||
│ PUT /api/brand/color │ │ │ │
|
||||
│ PUT /api/brand/info│ │ │ GET /api/brand │
|
||||
│ ↓ │ │ │ /config (JWT) │
|
||||
│ ┌─────────────┐ │ │ │ │ │
|
||||
│ │ PostgreSQL │◄────┼──────┼────┼────┘ │
|
||||
│ │ brand_config│ │ │ │ brandStore.config │
|
||||
│ └─────────────┘ │ │ │ → 各页面动态绑定 │
|
||||
│ │ │ │ │
|
||||
└──────────────────────┘ │ └──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### GET /api/brand/config
|
||||
|
||||
获取当前租户的品牌配置。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **方法** | `GET` |
|
||||
| **路径** | `/api/brand/config` |
|
||||
| **认证** | 需要 JWT Token(`Authorization: Bearer <token>`) |
|
||||
| **租户识别** | 从 JWT Claims 中的 `tenantId` 自动提取 |
|
||||
| **签名** | 需要签名头(项目统一签名机制) |
|
||||
|
||||
### 响应数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenantId": "default",
|
||||
"logoUrl": "http://localhost:8084/api/files/preview/brand/logo/logo_xxx.jpeg",
|
||||
"backgroundImageUrl": null,
|
||||
"primaryColor": "#00E676",
|
||||
"primaryColorRgb": "0,230,118",
|
||||
"secondaryColor": "#1A1A1A",
|
||||
"secondaryColorRgb": "26,26,26",
|
||||
"fontFamily": null,
|
||||
"brandName": "Novalon健身房",
|
||||
"slogan": "专业健身,从这里开始",
|
||||
"createdAt": "2026-07-23T17:37:36",
|
||||
"updatedAt": "2026-07-23T17:37:59"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | Long | 记录 ID |
|
||||
| `tenantId` | String | 租户 ID |
|
||||
| `logoUrl` | String | Logo 图片完整 URL |
|
||||
| `backgroundImageUrl` | String | 背景图完整 URL(可为 null) |
|
||||
| `primaryColor` | String | 主色调 HEX 值,如 `#00E676` |
|
||||
| `primaryColorRgb` | String | 主色调 RGB 值,如 `0,230,118` |
|
||||
| `secondaryColor` | String | 辅助色 HEX 值,如 `#1A1A1A` |
|
||||
| `secondaryColorRgb` | String | 辅助色 RGB 值,如 `26,26,26` |
|
||||
| `fontFamily` | String | 字体族(预留) |
|
||||
| `brandName` | String | 品牌名称,最长 100 字符 |
|
||||
| `slogan` | String | 品牌口号,最长 200 字符 |
|
||||
| `createdAt` | String | 创建时间(ISO 格式) |
|
||||
| `updatedAt` | String | 最后更新时间(ISO 格式),用作版本号 |
|
||||
|
||||
---
|
||||
|
||||
## 小程序端接入指南
|
||||
|
||||
### 文件结构
|
||||
|
||||
两个小程序项目结构相同:
|
||||
|
||||
```
|
||||
项目根目录/
|
||||
├── api/
|
||||
│ └── brand.js ← 品牌配置 API 层
|
||||
├── store/
|
||||
│ └── brand.js ← 品牌配置 Store(含缓存逻辑)
|
||||
├── App.vue ← 入口,冷启动/前台切换时拉取配置
|
||||
└── pages/
|
||||
├── index/index.vue ← 首页
|
||||
├── login/login.vue ← 登录页
|
||||
├── profile/profile.vue← 个人中心
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 1. api/brand.js — API 调用层
|
||||
|
||||
```js
|
||||
const request = require('../utils/request')
|
||||
|
||||
module.exports = {
|
||||
getBrandConfig() {
|
||||
return request('/brand/config', { method: 'GET' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 复用项目现有的 `luch-request` 封装,自动注入 JWT Token 和签名头
|
||||
- 返回 `Promise<Object>`,即 `BrandConfig` 对象
|
||||
|
||||
### 2. store/brand.js — 品牌配置 Store
|
||||
|
||||
```js
|
||||
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: ''
|
||||
}
|
||||
|
||||
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) {}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
},
|
||||
|
||||
async fetchConfig() {
|
||||
let cached = null
|
||||
try { cached = uni.getStorageSync(BRAND_CACHE_KEY) } catch (e) {}
|
||||
|
||||
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
|
||||
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
|
||||
this._loaded = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BrandStore] 获取品牌配置失败:', e)
|
||||
}
|
||||
this._loaded = true
|
||||
return false
|
||||
},
|
||||
|
||||
applyTabBar() {
|
||||
const cfg = this.config
|
||||
uni.setTabBarStyle({
|
||||
color: '#7A7E84',
|
||||
selectedColor: cfg.primaryColor,
|
||||
backgroundColor: cfg.secondaryColor,
|
||||
borderStyle: 'black'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = brandStore
|
||||
```
|
||||
|
||||
### 3. App.vue — 应用入口
|
||||
|
||||
```html
|
||||
<script>
|
||||
const brandStore = require('./store/brand')
|
||||
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
brandStore.fetchConfig().then(changed => {
|
||||
if (changed) brandStore.applyTabBar()
|
||||
})
|
||||
setTimeout(() => { brandStore.applyTabBar() }, 300)
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
brandStore.fetchConfig().then(changed => {
|
||||
if (changed) brandStore.applyTabBar()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. 页面模板接入示例
|
||||
|
||||
以首页为例,将硬编码颜色替换为动态绑定:
|
||||
|
||||
```html
|
||||
<!-- 导航栏:辅助色背景 -->
|
||||
<view class="header-wrap" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||
<view class="nav-bar" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||
<text class="nav-title">✦ 今日训练</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 品牌展示:动态名称/Slogan/Logo -->
|
||||
<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" :style="{
|
||||
backgroundColor: brandConfig.secondaryColor,
|
||||
backgroundImage: brandConfig.backgroundImageUrl ? 'url(' + brandConfig.backgroundImageUrl + ')' : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}">
|
||||
<!-- ... -->
|
||||
</view>
|
||||
|
||||
<!-- 主色调元素 -->
|
||||
<view class="checkin-badge" :style="{
|
||||
backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.2)',
|
||||
color: brandConfig.primaryColor
|
||||
}">
|
||||
✓ 今日已签到
|
||||
</view>
|
||||
|
||||
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">128</text>
|
||||
```
|
||||
|
||||
对应的 script 部分:
|
||||
|
||||
```js
|
||||
const brandStore = require('../../store/brand')
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
brandConfig() { return brandStore.config }
|
||||
},
|
||||
onShow() {
|
||||
brandStore.fetchConfig()
|
||||
// ... other data loading
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
brandStore.fetchConfig().finally(() => { uni.stopPullDownRefresh() })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 品牌字段与页面元素映射表
|
||||
|
||||
| 品牌字段 | 应用的元素 |
|
||||
|---------|-----------|
|
||||
| `primaryColor` | 按钮背景色、选中标签背景色、价格文字、难度星星高亮、签到成功标记、功能入口图标 |
|
||||
| `primaryColorRgb` | 半透明遮罩背景(如签到徽章 `rgba(xxx, 0.2)`)、功能入口圆形容器背景 |
|
||||
| `secondaryColor` | 自定义导航栏背景、问候卡片背景、统计卡片背景 |
|
||||
| `secondaryColorRgb` | 半透明副色遮罩 |
|
||||
| `logoUrl` | 首页品牌展示 Logo 图片 `src` 属性 |
|
||||
| `backgroundImageUrl` | 问候卡片背景图(CSS `background-image`) |
|
||||
| `brandName` | 首页品牌名称文本、登录页应用名称 |
|
||||
| `slogan` | 首页品牌口号文本、登录页口号文本 |
|
||||
|
||||
---
|
||||
|
||||
## 更新时机
|
||||
|
||||
| 时机 | 触发方式 | 说明 |
|
||||
|------|---------|------|
|
||||
| **冷启动** | `App.vue → onLaunch` | 先读本地缓存立即渲染,后台异步拉取,对比 `updatedAt` 决定是否更新 |
|
||||
| **切回前台** | `App.vue → onShow` | 用户从后台切回时拉取最新配置 |
|
||||
| **下拉刷新** | 各页面 `onPullDownRefresh` | 手动刷新时同步拉取品牌配置 |
|
||||
| **TabBar 切换** | `App.vue → onShow` + 各页面 `onShow` | Tab 页切换时所在页触发 |
|
||||
|
||||
---
|
||||
|
||||
## 缓存策略
|
||||
|
||||
```
|
||||
本地缓存 Key: brand_config_cache
|
||||
|
||||
存储数据: { brandName, slogan, primaryColor, primaryColorRgb,
|
||||
secondaryColor, secondaryColorRgb, logoUrl,
|
||||
backgroundImageUrl, updatedAt }
|
||||
|
||||
更新策略:
|
||||
1. 启动时读取缓存 → 立即渲染
|
||||
2. 后台调 API GET /api/brand/config
|
||||
3. 对比 API 返回的 updatedAt 与缓存的 updatedAt
|
||||
- 相同 → 不更新,节省渲染开销
|
||||
- 不同 → 更新内存 + 本地缓存,触发 UI 刷新
|
||||
4. 如果 API 调用失败 → 保持缓存值不变,不影响用户体验
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及的页面清单
|
||||
|
||||
### 会员端 (`gym-manage-uniapp`)
|
||||
|
||||
| 页面 | 文件 | 动态元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | `pages/index/index.vue` | 品牌展示(名称/口号/Logo)、导航栏背景、问候卡背景/背景图、签到徽章、统计数据、课程卡片价格、查看全部链接 |
|
||||
| 团课搜索 | `pages/search/search.vue` | 导航栏背景、搜索区背景、时间段筛选、类型筛选、排序激活态、预约按钮、课程价格 |
|
||||
| 我的课程 | `pages/my-courses/my-courses.vue` | 导航栏背景、空状态跳转链接 |
|
||||
| 课程详情 | `pages/course-detail/course-detail.vue` | 导航栏背景、封面渐变、预约按钮 |
|
||||
| 个人中心 | `pages/profile/profile.vue` | 导航栏背景、统计卡片背景、签到数字、会员标签、签到标记、保存按钮 |
|
||||
| 登录 | `pages/login/login.vue` | 品牌名称/口号、Logo 图标、微信登录按钮、协议链接 |
|
||||
|
||||
### 教练端 (`gym-manage-coach-uniapp`)
|
||||
|
||||
| 页面 | 文件 | 动态元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | `pages/index/index.vue` | 导航栏背景、欢迎卡片、教练名、统计数字、功能入口图标 |
|
||||
| 课程列表 | `pages/course-list/course-list.vue` | 导航栏背景、Tab 筛选、状态标签 |
|
||||
| 课程详情 | `pages/course-detail/course-detail.vue` | 导航栏背景、封面渐变、开课按钮、价格文字 |
|
||||
| 个人中心 | `pages/profile/profile.vue` | 导航栏背景、头像容器、标签颜色、统计卡片、评分徽章、公式按钮 |
|
||||
| 登录 | `pages/login/login.vue` | 品牌名称/口号、Logo 图标、登录按钮 |
|
||||
|
||||
---
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 后端不可用时怎么办?
|
||||
A: `brandStore` 内置默认配置(与原硬编码值完全一致),API 失败时自动回退到默认值,用户无感知。
|
||||
|
||||
### Q: 如何确认品牌配置已生效?
|
||||
A: 后台管理端修改配色/Logo/名称并保存后,小程序重新打开或下拉刷新即可看到变化。冷启动首次渲染使用缓存值(上次成功拉取的),后台静默更新。
|
||||
|
||||
### Q: 不同租户会互相影响吗?
|
||||
A: 不会。`GET /api/brand/config` 从 JWT Token 中提取 `tenantId`,只返回当前租户的配置。数据库层面也有唯一索引 `UNIQUE(tenant_id) WHERE deleted_at IS NULL` 保证隔离。
|
||||
Reference in New Issue
Block a user