feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置

This commit is contained in:
2025-07-29 07:38:47 +08:00
parent cc886cd4d5
commit 2f3d39fb00
142 changed files with 45645 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
# 情绪博物馆后端重构项目
## 项目概述
本项目是基于Spring Boot 3.4.8的情绪博物馆后端服务重构版本,从原有的Spring Boot 2.7.18升级而来。
## 重构进度
### 第一阶段:基础环境升级 ✅
#### 已完成的工作
1. **项目结构创建**
- 创建了标准的Maven项目结构
- 配置了src/main/java和src/main/resources目录
2. **Maven配置**
- 创建了pom.xml文件
- 配置了Spring Boot 3.4.8作为父项目
- 添加了所有必要的依赖:
- Spring Boot Starters (Web, Security, WebSocket, Redis, Validation, Actuator)
- MyBatis-Plus 3.5.5
- JWT 0.12.3
- SpringDoc OpenAPI 3
- Hutool 5.8.25
- Lombok
3. **配置文件**
- application.yml (主配置文件)
- application-local.yml (本地环境配置)
- 配置了数据库连接、Redis、日志等
4. **基础配置类**
- SecurityConfig (Spring Security 6.x配置)
- MybatisPlusConfig (MyBatis-Plus配置)
- RedisConfig (Redis配置)
- AsyncConfig (异步配置)
- OpenApiConfig (OpenAPI配置)
- WebClientConfig (HTTP客户端配置)
- CozeConfig (Coze API配置)
5. **主启动类**
- EmotionMuseumApplication.java
- 配置了组件扫描、缓存、异步、事务管理
6. **基础控制器**
- HealthController (健康检查接口)
#### 技术栈升级
- **Spring Boot**: 2.7.18 → 3.4.8 ✅
- **Java版本**: JDK 17 (当前使用,计划升级到JDK 21)
- **Spring Security**: 5.x → 6.x ✅
- **MyBatis-Plus**: 3.5.3.1 → 3.5.5 ✅
- **JWT**: 0.11.5 → 0.12.3 ✅
- **API文档**: Swagger → SpringDoc OpenAPI 3 ✅
#### 当前状态
- ✅ 项目能够正常编译
- ✅ 基础配置完成
- ✅ 应用程序启动成功
### 第二阶段:核心功能重构 🔄
#### 已完成的工作
1. **实体类创建**
- User (用户实体)
- DiaryPost (日记实体)
- Message (消息实体)
- Conversation (会话实体)
2. **DTO类创建**
- Result (通用响应DTO)
- LoginRequest/LoginResponse (登录相关DTO)
- RegisterRequest (注册DTO)
3. **Mapper接口创建**
- UserMapper (用户数据访问)
- DiaryPostMapper (日记数据访问)
- MessageMapper (消息数据访问)
- ConversationMapper (会话数据访问)
4. **工具类创建**
- JwtUtil (JWT工具类,适配JWT 0.12.3)
5. **认证系统重构**
- AuthService (认证服务接口)
- AuthServiceImpl (认证服务实现)
- AuthController (认证控制器)
- 支持用户注册、登录、登出、令牌刷新等功能
6. **用户管理系统重构**
- UserService (用户服务接口)
- UserServiceImpl (用户服务实现)
- UserController (用户控制器)
- 支持用户信息管理、密码修改、用户列表等功能
7. **AI对话系统重构**
- CozeApiService (Coze API服务接口)
- CozeApiServiceImpl (Coze API服务实现)
- AiChatService (AI聊天服务接口)
- AiChatServiceImpl (AI聊天服务实现)
- AiChatController (AI聊天控制器)
- 支持与Coze Bot的对话、会话管理、消息历史等功能
8. **日记系统重构**
- DiaryPostRequest (日记请求DTO)
- DiaryPostService (日记服务接口)
- DiaryPostServiceImpl (日记服务实现)
- DiaryPostController (日记控制器)
- 支持日记CRUD、AI点评、点赞、情绪标签等功能
9. **WebSocket系统重构**
- WebSocketConfig (WebSocket配置)
- ChatMessage (WebSocket消息DTO)
- WebSocketController (WebSocket控制器)
- 支持实时聊天、AI对话、消息推送等功能
10. **社区系统重构** 🔄
- Comment (评论实体)
- UserFollow (用户关注实体)
- CommentRequest/CommentResponse (评论DTO)
- CommentMapper/UserFollowMapper (数据访问层)
- CommentService/UserFollowService (服务接口)
- CommentServiceImpl (评论服务实现)
- 支持评论、回复、点赞、用户关注等功能
#### 当前状态
- ✅ 认证系统重构完成
- ✅ 用户管理系统重构完成
- ✅ AI对话系统重构完成
- ✅ 日记系统重构完成
- ✅ WebSocket系统重构完成
- ✅ 社区系统基础重构完成
- ✅ JWT 0.12.3适配完成
- ✅ 基础数据访问层完成
- 🔄 继续其他核心功能重构
## 下一步计划
### 第二阶段:核心功能重构
1. **认证系统重构**
- 重构AuthController
- 升级JWT认证机制
- 优化Spring Security配置
2. **AI对话系统重构**
- 重构AiChatController
- 实现Coze API客户端
- 优化异步处理机制
3. **用户管理系统重构**
- 重构UserController
- 优化用户信息管理
4. **日记系统重构**
- 重构DiaryPostController
- 优化日记CRUD操作
## 运行说明
### 环境要求
- JDK 17+
- Maven 3.6+
- MySQL 8.0+
- Redis 7.0+
### 启动步骤
1. 配置环境变量或修改application-local.yml中的数据库和Redis连接信息
2. 执行编译:`mvn clean compile`
3. 启动应用:`mvn spring-boot:run`
### 访问地址
- 应用地址:http://localhost:19089
- API文档:http://localhost:19089/api/swagger-ui.html
- 健康检查:http://localhost:19089/api/health
## 注意事项
1. 当前使用JDK 17,后续将升级到JDK 21
2. 数据库和Redis需要提前启动并配置
3. Coze API配置需要设置环境变量COZE_API_KEY和COZE_BOT_ID
## 问题记录
1. Spring Boot Actuator依赖问题 - 已解决
2. Spring Security 6.x配置调整 - 已解决
3. 应用程序启动测试 - 进行中
+175
View File
@@ -0,0 +1,175 @@
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.8</version>
<relativePath/>
</parent>
<groupId>com.emotionmuseum</groupId>
<artifactId>emotion-museum-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>emotion-museum-backend</name>
<description>情绪博物馆后端服务</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- HTTP客户端 (用于调用Coze API) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 数据库相关 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- API文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>false</skipTests>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,33 @@
package com.emotionmuseum;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* 情绪博物馆后端服务启动类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@SpringBootApplication
@MapperScan("com.emotionmuseum.mapper")
@EnableCaching
@EnableAsync
@EnableTransactionManagement
public class EmotionMuseumApplication {
public static void main(String[] args) {
SpringApplication.run(EmotionMuseumApplication.class, args);
System.out.println("=================================");
System.out.println("情绪博物馆后端服务启动成功!");
System.out.println("服务端口: 19089");
System.out.println("API文档: http://localhost:19089/api/swagger-ui.html");
System.out.println("健康检查: http://localhost:19089/api/health");
System.out.println("=================================");
}
}
@@ -0,0 +1,63 @@
package com.emotionmuseum.config;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
/**
* 默认异步执行器
*/
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(500);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("emotion-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
/**
* AI任务执行器
*/
@Bean("aiTaskExecutor")
public Executor aiTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("ai-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
/**
* 异步异常处理器
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler();
}
}
@@ -0,0 +1,64 @@
package com.emotionmuseum.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import jakarta.annotation.PostConstruct;
/**
* Coze配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@ConfigurationProperties(prefix = "emotion.coze")
@Data
@Slf4j
public class CozeConfig {
/**
* API密钥
*/
private String apiKey;
/**
* 机器人ID
*/
private String botId;
/**
* 基础URL
*/
private String baseUrl = "https://www.coze.cn/api";
/**
* 超时时间(毫秒)
*/
private int timeout = 30000;
/**
* 最大重试次数
*/
private int maxRetries = 3;
/**
* 验证配置
*/
@PostConstruct
public void validateConfig() {
if (!StringUtils.hasText(apiKey)) {
log.warn("Coze API Key未配置,AI功能可能无法正常使用");
}
if (!StringUtils.hasText(botId)) {
log.warn("Coze Bot ID未配置,AI功能可能无法正常使用");
}
if (StringUtils.hasText(apiKey) && StringUtils.hasText(botId)) {
log.info("Coze配置验证通过,Bot ID: {}", botId);
}
}
}
@@ -0,0 +1,55 @@
package com.emotionmuseum.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
public class MybatisPlusConfig {
/**
* MyBatis-Plus拦截器配置
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
/**
* 全局配置
*/
@Bean
public GlobalConfig globalConfig() {
GlobalConfig globalConfig = new GlobalConfig();
// 设置数据库类型
globalConfig.setDbConfig(new GlobalConfig.DbConfig());
globalConfig.getDbConfig().setIdType(com.baomidou.mybatisplus.annotation.IdType.ASSIGN_ID);
// 设置逻辑删除
globalConfig.getDbConfig().setLogicDeleteField("deleted");
globalConfig.getDbConfig().setLogicDeleteValue("1");
globalConfig.getDbConfig().setLogicNotDeleteValue("0");
return globalConfig;
}
}
@@ -0,0 +1,43 @@
package com.emotionmuseum.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
public class OpenApiConfig {
/**
* OpenAPI配置
*/
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("情绪博物馆API文档")
.version("1.0.0")
.description("情绪博物馆后端服务API文档")
.contact(new Contact()
.name("情绪博物馆团队")
.email("support@emotion-museum.com")))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -0,0 +1,76 @@
package com.emotionmuseum.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@EnableCaching
public class RedisConfig {
/**
* Redis模板配置
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用Jackson2JsonRedisSerializer
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
mapper.registerModule(new JavaTimeModule());
serializer.setObjectMapper(mapper);
// 设置序列化器
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
/**
* 缓存管理器配置
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@@ -0,0 +1,91 @@
package com.emotionmuseum.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
/**
* 安全过滤器链配置
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF
.csrf(AbstractHttpConfigurer::disable)
// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 会话管理
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 授权配置
.authorizeHttpRequests(authz -> authz
// 允许访问的路径
.requestMatchers("/health/**").permitAll()
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/ws/**").permitAll()
.requestMatchers("/ai/guest/**").permitAll()
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/swagger-ui.html").permitAll()
.requestMatchers("/favicon.ico").permitAll()
// 其他请求需要认证
.anyRequest().authenticated()
);
return http.build();
}
/**
* CORS配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许的源
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
// 允许的方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// 允许的头部
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证
configuration.setAllowCredentials(true);
// 预检请求的有效期
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
@@ -0,0 +1,72 @@
package com.emotionmuseum.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.io.IOException;
/**
* HTTP客户端配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@Slf4j
public class WebClientConfig {
/**
* WebClient构建器
*/
@Bean
public WebClient.Builder webClientBuilder() {
return WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.filter(ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
return Mono.just(clientRequest);
}))
.filter(ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
log.debug("Response: {}", clientResponse.statusCode());
return Mono.just(clientResponse);
}));
}
/**
* RestTemplate配置
*/
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
// 设置超时时间
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(30000);
factory.setReadTimeout(30000);
restTemplate.setRequestFactory(factory);
// 添加请求拦截器
restTemplate.getInterceptors().add(new ClientHttpRequestInterceptor() {
@Override
public org.springframework.http.client.ClientHttpResponse intercept(
org.springframework.http.HttpRequest request,
byte[] body,
org.springframework.http.client.ClientHttpRequestExecution execution) throws IOException {
log.debug("Request: {} {}", request.getMethod(), request.getURI());
org.springframework.http.client.ClientHttpResponse response = execution.execute(request, body);
log.debug("Response: {}", response.getStatusCode());
return response;
}
});
return restTemplate;
}
}
@@ -0,0 +1,43 @@
package com.emotionmuseum.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket配置类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册STOMP端点
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
// 支持原生WebSocket
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 启用简单的消息代理
registry.enableSimpleBroker("/topic", "/queue");
// 设置应用程序目标前缀
registry.setApplicationDestinationPrefixes("/app");
// 设置用户目标前缀
registry.setUserDestinationPrefix("/user");
}
}
@@ -0,0 +1,173 @@
package com.emotionmuseum.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.Conversation;
import com.emotionmuseum.entity.Message;
import com.emotionmuseum.service.AiChatService;
import com.emotionmuseum.service.AuthService;
import com.emotionmuseum.service.CozeApiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* AI聊天控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@RestController
@RequestMapping("/ai")
@Tag(name = "AI聊天", description = "AI聊天相关接口")
@Slf4j
public class AiChatController {
@Autowired
private AiChatService aiChatService;
@Autowired
private CozeApiService cozeApiService;
@Autowired
private AuthService authService;
/**
* 发送消息
*/
@PostMapping("/chat/send")
@Operation(summary = "发送消息", description = "发送消息并获取AI回复")
public Result<Message> sendMessage(HttpServletRequest request,
@RequestParam String content,
@RequestParam(required = false) String conversationId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户发送消息: {}, 会话: {}", userId, conversationId);
return aiChatService.sendMessage(userId, content, conversationId);
}
/**
* 创建新会话
*/
@PostMapping("/conversation/create")
@Operation(summary = "创建会话", description = "创建新的聊天会话")
public Result<Conversation> createConversation(HttpServletRequest request,
@RequestParam(required = false) String title) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户创建会话: {}, 标题: {}", userId, title);
return aiChatService.createConversation(userId, title);
}
/**
* 获取用户会话列表
*/
@GetMapping("/conversation/list")
@Operation(summary = "获取会话列表", description = "获取当前用户的会话列表")
public Result<IPage<Conversation>> getUserConversations(HttpServletRequest request,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return aiChatService.getUserConversations(userId, page, size);
}
/**
* 获取会话消息列表
*/
@GetMapping("/conversation/{conversationId}/messages")
@Operation(summary = "获取会话消息", description = "获取指定会话的消息列表")
public Result<IPage<Message>> getConversationMessages(@PathVariable String conversationId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return aiChatService.getConversationMessages(conversationId, page, size);
}
/**
* 删除会话
*/
@DeleteMapping("/conversation/{conversationId}")
@Operation(summary = "删除会话", description = "删除指定的聊天会话")
public Result<String> deleteConversation(HttpServletRequest request,
@PathVariable String conversationId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户删除会话: {}, 会话: {}", userId, conversationId);
return aiChatService.deleteConversation(userId, conversationId);
}
/**
* 获取会话详情
*/
@GetMapping("/conversation/{conversationId}")
@Operation(summary = "获取会话详情", description = "获取指定会话的详细信息")
public Result<Conversation> getConversationById(@PathVariable String conversationId) {
return aiChatService.getConversationById(conversationId);
}
/**
* 清空会话消息
*/
@PostMapping("/conversation/{conversationId}/clear")
@Operation(summary = "清空会话", description = "清空指定会话的所有消息")
public Result<String> clearConversation(HttpServletRequest request,
@PathVariable String conversationId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户清空会话: {}, 会话: {}", userId, conversationId);
return aiChatService.clearConversation(userId, conversationId);
}
/**
* 检查AI服务状态
*/
@GetMapping("/status")
@Operation(summary = "检查AI状态", description = "检查AI服务是否正常运行")
public Result<Boolean> checkAiStatus() {
return cozeApiService.checkConnection();
}
/**
* 获取Bot信息
*/
@GetMapping("/bot/info")
@Operation(summary = "获取Bot信息", description = "获取AI机器人的详细信息")
public Result<String> getBotInfo() {
return cozeApiService.getBotInfo();
}
/**
* 从请求中提取令牌
*/
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,94 @@
package com.emotionmuseum.controller;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.auth.LoginRequest;
import com.emotionmuseum.dto.auth.LoginResponse;
import com.emotionmuseum.dto.auth.RegisterRequest;
import com.emotionmuseum.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 认证控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@RestController
@RequestMapping("/auth")
@Tag(name = "认证管理", description = "用户认证相关接口")
@Slf4j
public class AuthController {
@Autowired
private AuthService authService;
/**
* 用户登录
*/
@PostMapping("/login")
@Operation(summary = "用户登录", description = "用户登录接口")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
log.info("用户登录请求: {}", request.getUsername());
return authService.login(request);
}
/**
* 用户注册
*/
@PostMapping("/register")
@Operation(summary = "用户注册", description = "用户注册接口")
public Result<String> register(@Valid @RequestBody RegisterRequest request) {
log.info("用户注册请求: {}", request.getUsername());
return authService.register(request);
}
/**
* 用户登出
*/
@PostMapping("/logout")
@Operation(summary = "用户登出", description = "用户登出接口")
public Result<String> logout(HttpServletRequest request) {
String token = extractToken(request);
log.info("用户登出请求");
return authService.logout(token);
}
/**
* 刷新令牌
*/
@PostMapping("/refresh")
@Operation(summary = "刷新令牌", description = "刷新访问令牌")
public Result<String> refreshToken(@RequestParam String refreshToken) {
log.info("刷新令牌请求");
return authService.refreshToken(refreshToken);
}
/**
* 验证令牌
*/
@GetMapping("/validate")
@Operation(summary = "验证令牌", description = "验证访问令牌是否有效")
public Result<Boolean> validateToken(HttpServletRequest request) {
String token = extractToken(request);
boolean isValid = authService.validateToken(token);
return Result.success("令牌验证完成", isValid);
}
/**
* 从请求中提取令牌
*/
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,190 @@
package com.emotionmuseum.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.diary.DiaryPostRequest;
import com.emotionmuseum.entity.DiaryPost;
import com.emotionmuseum.service.AuthService;
import com.emotionmuseum.service.DiaryPostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 日记控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@RestController
@RequestMapping("/diary")
@Tag(name = "日记管理", description = "日记相关接口")
@Slf4j
public class DiaryPostController {
@Autowired
private DiaryPostService diaryPostService;
@Autowired
private AuthService authService;
/**
* 创建日记
*/
@PostMapping("/create")
@Operation(summary = "创建日记", description = "创建新的日记")
public Result<DiaryPost> createDiary(HttpServletRequest request, @Valid @RequestBody DiaryPostRequest diaryRequest) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户创建日记: {}", userId);
return diaryPostService.createDiary(userId, diaryRequest);
}
/**
* 更新日记
*/
@PutMapping("/{diaryId}")
@Operation(summary = "更新日记", description = "更新指定的日记")
public Result<String> updateDiary(HttpServletRequest request,
@PathVariable String diaryId,
@Valid @RequestBody DiaryPostRequest diaryRequest) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户更新日记: {}, 日记: {}", userId, diaryId);
return diaryPostService.updateDiary(userId, diaryId, diaryRequest);
}
/**
* 获取日记详情
*/
@GetMapping("/{diaryId}")
@Operation(summary = "获取日记详情", description = "获取指定日记的详细信息")
public Result<DiaryPost> getDiaryById(@PathVariable String diaryId) {
return diaryPostService.getDiaryById(diaryId);
}
/**
* 获取用户日记列表
*/
@GetMapping("/user/list")
@Operation(summary = "获取用户日记列表", description = "获取当前用户的日记列表")
public Result<IPage<DiaryPost>> getUserDiaries(HttpServletRequest request,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return diaryPostService.getUserDiaries(userId, page, size);
}
/**
* 获取公开日记列表
*/
@GetMapping("/public/list")
@Operation(summary = "获取公开日记列表", description = "获取所有公开的日记列表")
public Result<IPage<DiaryPost>> getPublicDiaries(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return diaryPostService.getPublicDiaries(page, size);
}
/**
* 根据情绪标签查询日记
*/
@GetMapping("/emotion/{emotionTag}")
@Operation(summary = "根据情绪标签查询日记", description = "根据情绪标签查询公开日记")
public Result<IPage<DiaryPost>> getDiariesByEmotionTag(@PathVariable String emotionTag,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return diaryPostService.getDiariesByEmotionTag(emotionTag, page, size);
}
/**
* 删除日记
*/
@DeleteMapping("/{diaryId}")
@Operation(summary = "删除日记", description = "删除指定的日记")
public Result<String> deleteDiary(HttpServletRequest request, @PathVariable String diaryId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户删除日记: {}, 日记: {}", userId, diaryId);
return diaryPostService.deleteDiary(userId, diaryId);
}
/**
* 点赞日记
*/
@PostMapping("/{diaryId}/like")
@Operation(summary = "点赞日记", description = "对指定日记进行点赞")
public Result<String> likeDiary(HttpServletRequest request, @PathVariable String diaryId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户点赞日记: {}, 日记: {}", userId, diaryId);
return diaryPostService.likeDiary(userId, diaryId);
}
/**
* 取消点赞
*/
@PostMapping("/{diaryId}/unlike")
@Operation(summary = "取消点赞", description = "取消对指定日记的点赞")
public Result<String> unlikeDiary(HttpServletRequest request, @PathVariable String diaryId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
log.info("用户取消点赞: {}, 日记: {}", userId, diaryId);
return diaryPostService.unlikeDiary(userId, diaryId);
}
/**
* 获取AI点评
*/
@GetMapping("/{diaryId}/ai-comment")
@Operation(summary = "获取AI点评", description = "获取指定日记的AI点评")
public Result<String> getAiComment(HttpServletRequest request, @PathVariable String diaryId) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return diaryPostService.getAiComment(userId, diaryId);
}
/**
* 从请求中提取令牌
*/
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,55 @@
package com.emotionmuseum.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 健康检查控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@RestController
@RequestMapping("/health")
@Slf4j
public class HealthController {
/**
* 健康检查接口
*/
@GetMapping
public Map<String, Object> health() {
log.info("健康检查请求");
Map<String, Object> result = new HashMap<>();
result.put("status", "UP");
result.put("timestamp", LocalDateTime.now());
result.put("service", "emotion-museum-backend");
result.put("version", "1.0.0");
result.put("message", "系统运行正常");
return result;
}
/**
* 系统信息接口
*/
@GetMapping("/info")
public Map<String, Object> info() {
Map<String, Object> result = new HashMap<>();
result.put("name", "情绪博物馆后端服务");
result.put("version", "1.0.0");
result.put("description", "基于Spring Boot 3.4.8的情绪博物馆后端服务");
result.put("javaVersion", System.getProperty("java.version"));
result.put("startTime", LocalDateTime.now());
return result;
}
}
@@ -0,0 +1,126 @@
package com.emotionmuseum.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.User;
import com.emotionmuseum.service.AuthService;
import com.emotionmuseum.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 用户控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@RestController
@RequestMapping("/user")
@Tag(name = "用户管理", description = "用户相关接口")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@Autowired
private AuthService authService;
/**
* 获取当前用户信息
*/
@GetMapping("/profile")
@Operation(summary = "获取用户信息", description = "获取当前登录用户的详细信息")
public Result<User> getProfile(HttpServletRequest request) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return userService.getUserById(userId);
}
/**
* 更新用户信息
*/
@PutMapping("/profile")
@Operation(summary = "更新用户信息", description = "更新当前登录用户的信息")
public Result<String> updateProfile(HttpServletRequest request, @Valid @RequestBody User user) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return userService.updateUser(userId, user);
}
/**
* 修改密码
*/
@PostMapping("/change-password")
@Operation(summary = "修改密码", description = "修改当前登录用户的密码")
public Result<String> changePassword(HttpServletRequest request,
@RequestParam String oldPassword,
@RequestParam String newPassword) {
String token = extractToken(request);
String userId = authService.getUserIdFromToken(token);
if (userId == null) {
return Result.unauthorized();
}
return userService.changePassword(userId, oldPassword, newPassword);
}
/**
* 获取用户列表(管理员功能)
*/
@GetMapping("/list")
@Operation(summary = "获取用户列表", description = "分页获取用户列表(管理员功能)")
public Result<IPage<User>> getUserList(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return userService.getUserList(page, size);
}
/**
* 根据ID获取用户信息(管理员功能)
*/
@GetMapping("/{userId}")
@Operation(summary = "获取指定用户信息", description = "根据用户ID获取用户信息(管理员功能)")
public Result<User> getUserById(@PathVariable String userId) {
return userService.getUserById(userId);
}
/**
* 删除用户(管理员功能)
*/
@DeleteMapping("/{userId}")
@Operation(summary = "删除用户", description = "删除指定用户(管理员功能)")
public Result<String> deleteUser(@PathVariable String userId) {
return userService.deleteUser(userId);
}
/**
* 启用/禁用用户(管理员功能)
*/
@PostMapping("/{userId}/status")
@Operation(summary = "更新用户状态", description = "启用或禁用用户(管理员功能)")
public Result<String> toggleUserStatus(@PathVariable String userId, @RequestParam Integer status) {
return userService.toggleUserStatus(userId, status);
}
/**
* 从请求中提取令牌
*/
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,139 @@
package com.emotionmuseum.controller;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.websocket.ChatMessage;
import com.emotionmuseum.service.AiChatService;
import com.emotionmuseum.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
/**
* WebSocket控制器
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Controller
@Slf4j
public class WebSocketController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private AiChatService aiChatService;
@Autowired
private AuthService authService;
/**
* 处理聊天消息
*/
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
log.info("收到WebSocket消息: {}", chatMessage.getContent());
return chatMessage;
}
/**
* 处理用户加入聊天
*/
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
// 添加用户名到WebSocket会话
headerAccessor.getSessionAttributes().put("username", chatMessage.getSenderId());
log.info("用户加入聊天: {}", chatMessage.getSenderId());
return chatMessage;
}
/**
* 处理AI聊天消息
*/
@MessageMapping("/ai.chat")
public void handleAiChat(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
try {
String userId = chatMessage.getSenderId();
String content = chatMessage.getContent();
String conversationId = chatMessage.getConversationId();
log.info("处理AI聊天消息: 用户={}, 内容={}, 会话={}", userId, content, conversationId);
// 调用AI服务获取回复
var result = aiChatService.sendMessage(userId, content, conversationId);
if (result.getCode() == 200) {
// 发送AI回复
ChatMessage aiResponse = new ChatMessage();
aiResponse.setType("CHAT");
aiResponse.setConversationId(conversationId);
aiResponse.setSenderId("AI");
aiResponse.setSenderType("AI");
aiResponse.setContent(result.getData().getContent());
aiResponse.setMessageType("TEXT");
// 发送给特定用户
messagingTemplate.convertAndSendToUser(
userId,
"/queue/ai.response",
aiResponse
);
log.info("AI回复发送成功: {}", aiResponse.getContent());
} else {
// 发送错误消息
ChatMessage errorResponse = new ChatMessage();
errorResponse.setType("ERROR");
errorResponse.setConversationId(conversationId);
errorResponse.setSenderId("SYSTEM");
errorResponse.setSenderType("SYSTEM");
errorResponse.setContent("抱歉,AI暂时无法回复,请稍后再试。");
errorResponse.setMessageType("TEXT");
messagingTemplate.convertAndSendToUser(
userId,
"/queue/ai.response",
errorResponse
);
log.error("AI回复失败: {}", result.getMessage());
}
} catch (Exception e) {
log.error("处理AI聊天消息时发生错误: {}", e.getMessage(), e);
// 发送错误消息
ChatMessage errorResponse = new ChatMessage();
errorResponse.setType("ERROR");
errorResponse.setConversationId(chatMessage.getConversationId());
errorResponse.setSenderId("SYSTEM");
errorResponse.setSenderType("SYSTEM");
errorResponse.setContent("系统错误,请稍后再试。");
errorResponse.setMessageType("TEXT");
messagingTemplate.convertAndSendToUser(
chatMessage.getSenderId(),
"/queue/ai.response",
errorResponse
);
}
}
/**
* 处理用户输入状态
*/
@MessageMapping("/chat.typing")
@SendTo("/topic/public")
public ChatMessage handleTyping(@Payload ChatMessage chatMessage) {
log.info("用户正在输入: {}", chatMessage.getSenderId());
return chatMessage;
}
}
@@ -0,0 +1,112 @@
package com.emotionmuseum.dto;
import lombok.Data;
/**
* 通用响应结果
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class Result<T> {
/**
* 响应码
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 时间戳
*/
private Long timestamp;
public Result() {
this.timestamp = System.currentTimeMillis();
}
public Result(Integer code, String message) {
this();
this.code = code;
this.message = message;
}
public Result(Integer code, String message, T data) {
this(code, message);
this.data = data;
}
/**
* 成功响应
*/
public static <T> Result<T> success() {
return new Result<>(200, "操作成功");
}
/**
* 成功响应(带数据)
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}
/**
* 成功响应(自定义消息)
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data);
}
/**
* 失败响应
*/
public static <T> Result<T> error() {
return new Result<>(500, "操作失败");
}
/**
* 失败响应(自定义消息)
*/
public static <T> Result<T> error(String message) {
return new Result<>(500, message);
}
/**
* 失败响应(自定义码和消息)
*/
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message);
}
/**
* 未授权响应
*/
public static <T> Result<T> unauthorized() {
return new Result<>(401, "未授权访问");
}
/**
* 禁止访问响应
*/
public static <T> Result<T> forbidden() {
return new Result<>(403, "禁止访问");
}
/**
* 资源不存在响应
*/
public static <T> Result<T> notFound() {
return new Result<>(404, "资源不存在");
}
}
@@ -0,0 +1,37 @@
package com.emotionmuseum.dto.auth;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class LoginRequest {
/**
* 用户名或邮箱
*/
@NotBlank(message = "用户名或邮箱不能为空")
private String username;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;
/**
* 验证码
*/
private String captcha;
/**
* 验证码ID
*/
private String captchaId;
}
@@ -0,0 +1,75 @@
package com.emotionmuseum.dto.auth;
import lombok.Data;
/**
* 登录响应DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class LoginResponse {
/**
* 访问令牌
*/
private String accessToken;
/**
* 刷新令牌
*/
private String refreshToken;
/**
* 令牌类型
*/
private String tokenType = "Bearer";
/**
* 过期时间(秒)
*/
private Long expiresIn;
/**
* 用户信息
*/
private UserInfo userInfo;
/**
* 用户信息
*/
@Data
public static class UserInfo {
/**
* 用户ID
*/
private String id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 邮箱
*/
private String email;
/**
* 头像
*/
private String avatar;
/**
* 用户类型
*/
private Integer userType;
}
}
@@ -0,0 +1,68 @@
package com.emotionmuseum.dto.auth;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 注册请求DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class RegisterRequest {
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度必须在3-20个字符之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线")
private String username;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20个字符之间")
private String password;
/**
* 确认密码
*/
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
/**
* 手机号
*/
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 昵称
*/
@Size(max = 50, message = "昵称长度不能超过50个字符")
private String nickname;
/**
* 验证码
*/
private String captcha;
/**
* 验证码ID
*/
private String captchaId;
}
@@ -0,0 +1,40 @@
package com.emotionmuseum.dto.comment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 评论请求DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class CommentRequest {
/**
* 父评论ID (用于回复功能)
*/
private String parentId;
/**
* 内容类型 (DIARY, POST)
*/
@NotBlank(message = "内容类型不能为空")
private String contentType;
/**
* 内容ID
*/
@NotBlank(message = "内容ID不能为空")
private String contentId;
/**
* 评论内容
*/
@NotBlank(message = "评论内容不能为空")
@Size(max = 1000, message = "评论内容长度不能超过1000个字符")
private String content;
}
@@ -0,0 +1,87 @@
package com.emotionmuseum.dto.comment;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 评论响应DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class CommentResponse {
/**
* 评论ID
*/
private String id;
/**
* 父评论ID
*/
private String parentId;
/**
* 内容类型
*/
private String contentType;
/**
* 内容ID
*/
private String contentId;
/**
* 评论者ID
*/
private String userId;
/**
* 评论者昵称
*/
private String userNickname;
/**
* 评论者头像
*/
private String userAvatar;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 是否已点赞
*/
private Boolean isLiked;
/**
* 状态
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 子评论列表
*/
private List<CommentResponse> replies;
}
@@ -0,0 +1,60 @@
package com.emotionmuseum.dto.diary;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 日记请求DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class DiaryPostRequest {
/**
* 标题
*/
@NotBlank(message = "标题不能为空")
@Size(max = 100, message = "标题长度不能超过100个字符")
private String title;
/**
* 内容
*/
@NotBlank(message = "内容不能为空")
@Size(max = 10000, message = "内容长度不能超过10000个字符")
private String content;
/**
* 情绪标签
*/
private String emotionTags;
/**
* 情绪评分 (1-10)
*/
private Integer emotionScore;
/**
* 天气
*/
private String weather;
/**
* 位置
*/
private String location;
/**
* 图片URL列表 (JSON格式)
*/
private String images;
/**
* 是否公开 (0:私密, 1:公开)
*/
private Integer isPublic = 0;
}
@@ -0,0 +1,63 @@
package com.emotionmuseum.dto.websocket;
import lombok.Data;
/**
* WebSocket聊天消息DTO
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
public class ChatMessage {
/**
* 消息类型 (CHAT, JOIN, LEAVE, TYPING)
*/
private String type;
/**
* 会话ID
*/
private String conversationId;
/**
* 发送者ID
*/
private String senderId;
/**
* 发送者类型 (USER, AI)
*/
private String senderType;
/**
* 消息内容
*/
private String content;
/**
* 消息类型 (TEXT, IMAGE, FILE)
*/
private String messageType;
/**
* 时间戳
*/
private Long timestamp;
public ChatMessage() {
this.timestamp = System.currentTimeMillis();
}
public ChatMessage(String type, String conversationId, String senderId, String senderType, String content) {
this();
this.type = type;
this.conversationId = conversationId;
this.senderId = senderId;
this.senderType = senderType;
this.content = content;
this.messageType = "TEXT";
}
}
@@ -0,0 +1,84 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 评论实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("comment")
public class Comment {
/**
* 评论ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 父评论ID (用于回复功能)
*/
private String parentId;
/**
* 内容类型 (DIARY, POST)
*/
private String contentType;
/**
* 内容ID
*/
private String contentId;
/**
* 评论者ID
*/
private String userId;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 状态 (0:待审核, 1:正常, 2:已删除)
*/
private Integer status;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,74 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 会话实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("conversation")
public class Conversation {
/**
* 会话ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 用户ID
*/
private String userId;
/**
* 会话标题
*/
private String title;
/**
* 会话类型 (CHAT, SUMMARY)
*/
private String conversationType;
/**
* 消息数量
*/
private Integer messageCount;
/**
* 最后消息时间
*/
private LocalDateTime lastMessageTime;
/**
* 会话状态 (0:进行中, 1:已结束, 2:已删除)
*/
private Integer status;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,114 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 日记实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("diary_post")
public class DiaryPost {
/**
* 日记ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 用户ID
*/
private String userId;
/**
* 标题
*/
private String title;
/**
* 内容
*/
private String content;
/**
* 情绪标签
*/
private String emotionTags;
/**
* 情绪评分 (1-10)
*/
private Integer emotionScore;
/**
* 天气
*/
private String weather;
/**
* 位置
*/
private String location;
/**
* 图片URL列表 (JSON格式)
*/
private String images;
/**
* 是否公开 (0:私密, 1:公开)
*/
private Integer isPublic;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 评论数
*/
private Integer commentCount;
/**
* 分享数
*/
private Integer shareCount;
/**
* AI点评
*/
private String aiComment;
/**
* 状态 (0:草稿, 1:已发布, 2:已删除)
*/
private Integer status;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,74 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 消息实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("message")
public class Message {
/**
* 消息ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 会话ID
*/
private String conversationId;
/**
* 发送者ID
*/
private String senderId;
/**
* 发送者类型 (USER, AI)
*/
private String senderType;
/**
* 消息内容
*/
private String content;
/**
* 消息类型 (TEXT, IMAGE, FILE)
*/
private String messageType;
/**
* 消息状态 (0:未读, 1:已读, 2:已发送, 3:发送失败)
*/
private Integer status;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,109 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 用户实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("user")
public class User {
/**
* 用户ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phone;
/**
* 昵称
*/
private String nickname;
/**
* 头像
*/
private String avatar;
/**
* 性别 (0:未知, 1:男, 2:女)
*/
private Integer gender;
/**
* 生日
*/
private LocalDateTime birthday;
/**
* 个人简介
*/
private String bio;
/**
* 状态 (0:禁用, 1:正常)
*/
private Integer status;
/**
* 用户类型 (0:普通用户, 1:VIP用户, 2:管理员)
*/
private Integer userType;
/**
* 最后登录时间
*/
private LocalDateTime lastLoginTime;
/**
* 最后登录IP
*/
private String lastLoginIp;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,59 @@
package com.emotionmuseum.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 用户关注实体类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("user_follow")
public class UserFollow {
/**
* 关注ID
*/
@TableId(type = IdType.ASSIGN_ID)
private String id;
/**
* 关注者ID
*/
private String followerId;
/**
* 被关注者ID
*/
private String followingId;
/**
* 关注状态 (0:取消关注, 1:已关注)
*/
private Integer status;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,52 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.entity.Comment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 评论Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface CommentMapper extends BaseMapper<Comment> {
/**
* 分页查询内容的评论
*/
@Select("SELECT * FROM comment WHERE content_type = #{contentType} AND content_id = #{contentId} AND parent_id IS NULL AND deleted = 0 ORDER BY create_time DESC")
IPage<Comment> selectContentComments(Page<Comment> page, @Param("contentType") String contentType, @Param("contentId") String contentId);
/**
* 查询评论的回复
*/
@Select("SELECT * FROM comment WHERE parent_id = #{parentId} AND deleted = 0 ORDER BY create_time ASC")
List<Comment> selectCommentReplies(@Param("parentId") String parentId);
/**
* 统计内容的评论数量
*/
@Select("SELECT COUNT(*) FROM comment WHERE content_type = #{contentType} AND content_id = #{contentId} AND deleted = 0")
int countByContent(@Param("contentType") String contentType, @Param("contentId") String contentId);
/**
* 统计用户的评论数量
*/
@Select("SELECT COUNT(*) FROM comment WHERE user_id = #{userId} AND deleted = 0")
int countByUserId(@Param("userId") String userId);
/**
* 查询用户的最新评论
*/
@Select("SELECT * FROM comment WHERE user_id = #{userId} AND deleted = 0 ORDER BY create_time DESC LIMIT #{limit}")
List<Comment> selectUserLatestComments(@Param("userId") String userId, @Param("limit") int limit);
}
@@ -0,0 +1,38 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.entity.Conversation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 会话Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface ConversationMapper extends BaseMapper<Conversation> {
/**
* 分页查询用户会话
*/
@Select("SELECT * FROM conversation WHERE user_id = #{userId} AND deleted = 0 ORDER BY last_message_time DESC")
IPage<Conversation> selectUserConversations(Page<Conversation> page, @Param("userId") String userId);
/**
* 统计用户会话数量
*/
@Select("SELECT COUNT(*) FROM conversation WHERE user_id = #{userId} AND deleted = 0")
int countByUserId(@Param("userId") String userId);
/**
* 查询用户最新的会话
*/
@Select("SELECT * FROM conversation WHERE user_id = #{userId} AND deleted = 0 ORDER BY last_message_time DESC LIMIT 1")
Conversation selectLatestConversation(@Param("userId") String userId);
}
@@ -0,0 +1,50 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.entity.DiaryPost;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 日记Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface DiaryPostMapper extends BaseMapper<DiaryPost> {
/**
* 分页查询用户的日记
*/
@Select("SELECT * FROM diary_post WHERE user_id = #{userId} AND deleted = 0 ORDER BY create_time DESC")
IPage<DiaryPost> selectUserDiaries(Page<DiaryPost> page, @Param("userId") String userId);
/**
* 分页查询公开的日记
*/
@Select("SELECT * FROM diary_post WHERE is_public = 1 AND status = 1 AND deleted = 0 ORDER BY create_time DESC")
IPage<DiaryPost> selectPublicDiaries(Page<DiaryPost> page);
/**
* 根据情绪标签查询日记
*/
@Select("SELECT * FROM diary_post WHERE emotion_tags LIKE CONCAT('%', #{emotionTag}, '%') AND is_public = 1 AND status = 1 AND deleted = 0 ORDER BY create_time DESC")
IPage<DiaryPost> selectDiariesByEmotionTag(Page<DiaryPost> page, @Param("emotionTag") String emotionTag);
/**
* 统计用户的日记数量
*/
@Select("SELECT COUNT(*) FROM diary_post WHERE user_id = #{userId} AND deleted = 0")
int countByUserId(@Param("userId") String userId);
/**
* 统计公开日记数量
*/
@Select("SELECT COUNT(*) FROM diary_post WHERE is_public = 1 AND status = 1 AND deleted = 0")
int countPublicDiaries();
}
@@ -0,0 +1,52 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.entity.Message;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 消息Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
/**
* 分页查询会话消息
*/
@Select("SELECT * FROM message WHERE conversation_id = #{conversationId} AND deleted = 0 ORDER BY create_time ASC")
IPage<Message> selectConversationMessages(Page<Message> page, @Param("conversationId") String conversationId);
/**
* 查询会话的最新消息
*/
@Select("SELECT * FROM message WHERE conversation_id = #{conversationId} AND deleted = 0 ORDER BY create_time DESC LIMIT 1")
Message selectLatestMessage(@Param("conversationId") String conversationId);
/**
* 查询会话的所有消息
*/
@Select("SELECT * FROM message WHERE conversation_id = #{conversationId} AND deleted = 0 ORDER BY create_time ASC")
List<Message> selectAllMessages(@Param("conversationId") String conversationId);
/**
* 统计会话消息数量
*/
@Select("SELECT COUNT(*) FROM message WHERE conversation_id = #{conversationId} AND deleted = 0")
int countByConversationId(@Param("conversationId") String conversationId);
/**
* 统计用户未读消息数量
*/
@Select("SELECT COUNT(*) FROM message m JOIN conversation c ON m.conversation_id = c.id WHERE c.user_id = #{userId} AND m.sender_type = 'AI' AND m.status = 0 AND m.deleted = 0")
int countUnreadMessages(@Param("userId") String userId);
}
@@ -0,0 +1,50 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.entity.UserFollow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 用户关注Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface UserFollowMapper extends BaseMapper<UserFollow> {
/**
* 分页查询用户的关注列表
*/
@Select("SELECT * FROM user_follow WHERE follower_id = #{followerId} AND status = 1 AND deleted = 0 ORDER BY create_time DESC")
IPage<UserFollow> selectUserFollowings(Page<UserFollow> page, @Param("followerId") String followerId);
/**
* 分页查询用户的粉丝列表
*/
@Select("SELECT * FROM user_follow WHERE following_id = #{followingId} AND status = 1 AND deleted = 0 ORDER BY create_time DESC")
IPage<UserFollow> selectUserFollowers(Page<UserFollow> page, @Param("followingId") String followingId);
/**
* 检查是否已关注
*/
@Select("SELECT COUNT(*) FROM user_follow WHERE follower_id = #{followerId} AND following_id = #{followingId} AND status = 1 AND deleted = 0")
int checkIsFollowing(@Param("followerId") String followerId, @Param("followingId") String followingId);
/**
* 统计用户关注数量
*/
@Select("SELECT COUNT(*) FROM user_follow WHERE follower_id = #{followerId} AND status = 1 AND deleted = 0")
int countFollowings(@Param("followerId") String followerId);
/**
* 统计用户粉丝数量
*/
@Select("SELECT COUNT(*) FROM user_follow WHERE following_id = #{followingId} AND status = 1 AND deleted = 0")
int countFollowers(@Param("followingId") String followingId);
}
@@ -0,0 +1,54 @@
package com.emotionmuseum.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 用户Mapper接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户
*/
@Select("SELECT * FROM user WHERE username = #{username} AND deleted = 0")
User findByUsername(@Param("username") String username);
/**
* 根据邮箱查询用户
*/
@Select("SELECT * FROM user WHERE email = #{email} AND deleted = 0")
User findByEmail(@Param("email") String email);
/**
* 根据手机号查询用户
*/
@Select("SELECT * FROM user WHERE phone = #{phone} AND deleted = 0")
User findByPhone(@Param("phone") String phone);
/**
* 检查用户名是否存在
*/
@Select("SELECT COUNT(*) FROM user WHERE username = #{username} AND deleted = 0")
int countByUsername(@Param("username") String username);
/**
* 检查邮箱是否存在
*/
@Select("SELECT COUNT(*) FROM user WHERE email = #{email} AND deleted = 0")
int countByEmail(@Param("email") String email);
/**
* 检查手机号是否存在
*/
@Select("SELECT COUNT(*) FROM user WHERE phone = #{phone} AND deleted = 0")
int countByPhone(@Param("phone") String phone);
}
@@ -0,0 +1,53 @@
package com.emotionmuseum.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.Conversation;
import com.emotionmuseum.entity.Message;
import java.util.List;
/**
* AI聊天服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface AiChatService {
/**
* 发送消息并获取AI回复
*/
Result<Message> sendMessage(String userId, String content, String conversationId);
/**
* 创建新会话
*/
Result<Conversation> createConversation(String userId, String title);
/**
* 获取用户会话列表
*/
Result<IPage<Conversation>> getUserConversations(String userId, int page, int size);
/**
* 获取会话消息列表
*/
Result<IPage<Message>> getConversationMessages(String conversationId, int page, int size);
/**
* 删除会话
*/
Result<String> deleteConversation(String userId, String conversationId);
/**
* 获取会话详情
*/
Result<Conversation> getConversationById(String conversationId);
/**
* 清空会话消息
*/
Result<String> clearConversation(String userId, String conversationId);
}
@@ -0,0 +1,46 @@
package com.emotionmuseum.service;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.auth.LoginRequest;
import com.emotionmuseum.dto.auth.LoginResponse;
import com.emotionmuseum.dto.auth.RegisterRequest;
/**
* 认证服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface AuthService {
/**
* 用户登录
*/
Result<LoginResponse> login(LoginRequest request);
/**
* 用户注册
*/
Result<String> register(RegisterRequest request);
/**
* 用户登出
*/
Result<String> logout(String token);
/**
* 刷新令牌
*/
Result<String> refreshToken(String refreshToken);
/**
* 验证令牌
*/
boolean validateToken(String token);
/**
* 从令牌中获取用户ID
*/
String getUserIdFromToken(String token);
}
@@ -0,0 +1,58 @@
package com.emotionmuseum.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.comment.CommentRequest;
import com.emotionmuseum.dto.comment.CommentResponse;
import java.util.List;
/**
* 评论服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface CommentService {
/**
* 创建评论
*/
Result<CommentResponse> createComment(String userId, CommentRequest request);
/**
* 获取内容评论列表
*/
Result<IPage<CommentResponse>> getContentComments(String contentType, String contentId, int page, int size);
/**
* 获取评论详情
*/
Result<CommentResponse> getCommentById(String commentId);
/**
* 删除评论
*/
Result<String> deleteComment(String userId, String commentId);
/**
* 点赞评论
*/
Result<String> likeComment(String userId, String commentId);
/**
* 取消点赞评论
*/
Result<String> unlikeComment(String userId, String commentId);
/**
* 获取用户评论列表
*/
Result<IPage<CommentResponse>> getUserComments(String userId, int page, int size);
/**
* 获取评论回复列表
*/
Result<List<CommentResponse>> getCommentReplies(String commentId);
}
@@ -0,0 +1,33 @@
package com.emotionmuseum.service;
import com.emotionmuseum.dto.Result;
/**
* Coze API服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface CozeApiService {
/**
* 发送消息到Coze Bot
*/
Result<String> sendMessage(String message, String userId);
/**
* 发送消息到Coze Bot(带上下文)
*/
Result<String> sendMessageWithContext(String message, String userId, String conversationId);
/**
* 获取Bot信息
*/
Result<String> getBotInfo();
/**
* 检查API连接状态
*/
Result<Boolean> checkConnection();
}
@@ -0,0 +1,66 @@
package com.emotionmuseum.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.diary.DiaryPostRequest;
import com.emotionmuseum.entity.DiaryPost;
/**
* 日记服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface DiaryPostService {
/**
* 创建日记
*/
Result<DiaryPost> createDiary(String userId, DiaryPostRequest request);
/**
* 更新日记
*/
Result<String> updateDiary(String userId, String diaryId, DiaryPostRequest request);
/**
* 获取日记详情
*/
Result<DiaryPost> getDiaryById(String diaryId);
/**
* 获取用户日记列表
*/
Result<IPage<DiaryPost>> getUserDiaries(String userId, int page, int size);
/**
* 获取公开日记列表
*/
Result<IPage<DiaryPost>> getPublicDiaries(int page, int size);
/**
* 根据情绪标签查询日记
*/
Result<IPage<DiaryPost>> getDiariesByEmotionTag(String emotionTag, int page, int size);
/**
* 删除日记
*/
Result<String> deleteDiary(String userId, String diaryId);
/**
* 点赞日记
*/
Result<String> likeDiary(String userId, String diaryId);
/**
* 取消点赞
*/
Result<String> unlikeDiary(String userId, String diaryId);
/**
* 获取AI点评
*/
Result<String> getAiComment(String userId, String diaryId);
}
@@ -0,0 +1,50 @@
package com.emotionmuseum.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.UserFollow;
/**
* 用户关注服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface UserFollowService {
/**
* 关注用户
*/
Result<String> followUser(String followerId, String followingId);
/**
* 取消关注
*/
Result<String> unfollowUser(String followerId, String followingId);
/**
* 检查是否已关注
*/
Result<Boolean> checkIsFollowing(String followerId, String followingId);
/**
* 获取用户关注列表
*/
Result<IPage<UserFollow>> getUserFollowings(String userId, int page, int size);
/**
* 获取用户粉丝列表
*/
Result<IPage<UserFollow>> getUserFollowers(String userId, int page, int size);
/**
* 获取用户关注数量
*/
Result<Integer> getFollowingsCount(String userId);
/**
* 获取用户粉丝数量
*/
Result<Integer> getFollowersCount(String userId);
}
@@ -0,0 +1,45 @@
package com.emotionmuseum.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.User;
/**
* 用户服务接口
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
public interface UserService {
/**
* 根据ID获取用户信息
*/
Result<User> getUserById(String userId);
/**
* 更新用户信息
*/
Result<String> updateUser(String userId, User user);
/**
* 修改密码
*/
Result<String> changePassword(String userId, String oldPassword, String newPassword);
/**
* 分页查询用户列表
*/
Result<IPage<User>> getUserList(int page, int size);
/**
* 删除用户
*/
Result<String> deleteUser(String userId);
/**
* 启用/禁用用户
*/
Result<String> toggleUserStatus(String userId, Integer status);
}
@@ -0,0 +1,250 @@
package com.emotionmuseum.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.Conversation;
import com.emotionmuseum.entity.Message;
import com.emotionmuseum.mapper.ConversationMapper;
import com.emotionmuseum.mapper.MessageMapper;
import com.emotionmuseum.service.AiChatService;
import com.emotionmuseum.service.CozeApiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI聊天服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class AiChatServiceImpl implements AiChatService {
@Autowired
private ConversationMapper conversationMapper;
@Autowired
private MessageMapper messageMapper;
@Autowired
private CozeApiService cozeApiService;
@Override
@Transactional
public Result<Message> sendMessage(String userId, String content, String conversationId) {
try {
// 验证会话是否存在
Conversation conversation = null;
if (StringUtils.hasText(conversationId)) {
conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
if (!conversation.getUserId().equals(userId)) {
return Result.error("无权访问此会话");
}
} else {
// 创建新会话
conversation = new Conversation();
conversation.setUserId(userId);
conversation.setTitle("新对话");
conversation.setConversationType("CHAT");
conversation.setMessageCount(0);
conversation.setStatus(0);
conversation.setCreateTime(LocalDateTime.now());
conversation.setUpdateTime(LocalDateTime.now());
conversationMapper.insert(conversation);
}
// 保存用户消息
Message userMessage = new Message();
userMessage.setConversationId(conversation.getId());
userMessage.setSenderId(userId);
userMessage.setSenderType("USER");
userMessage.setContent(content);
userMessage.setMessageType("TEXT");
userMessage.setStatus(2); // 已发送
userMessage.setCreateTime(LocalDateTime.now());
userMessage.setUpdateTime(LocalDateTime.now());
messageMapper.insert(userMessage);
// 异步调用AI获取回复
String aiReply = getAiReply(userId, content, conversation.getId());
// 保存AI回复
Message aiMessage = new Message();
aiMessage.setConversationId(conversation.getId());
aiMessage.setSenderId("AI");
aiMessage.setSenderType("AI");
aiMessage.setContent(aiReply);
aiMessage.setMessageType("TEXT");
aiMessage.setStatus(2); // 已发送
aiMessage.setCreateTime(LocalDateTime.now());
aiMessage.setUpdateTime(LocalDateTime.now());
messageMapper.insert(aiMessage);
// 更新会话信息
conversation.setMessageCount(conversation.getMessageCount() + 2);
conversation.setLastMessageTime(LocalDateTime.now());
conversation.setUpdateTime(LocalDateTime.now());
conversationMapper.updateById(conversation);
log.info("消息发送成功,用户: {}, 会话: {}", userId, conversation.getId());
return Result.success("消息发送成功", aiMessage);
} catch (Exception e) {
log.error("发送消息失败: {}", e.getMessage(), e);
return Result.error("发送消息失败: " + e.getMessage());
}
}
@Override
public Result<Conversation> createConversation(String userId, String title) {
try {
Conversation conversation = new Conversation();
conversation.setUserId(userId);
conversation.setTitle(StringUtils.hasText(title) ? title : "新对话");
conversation.setConversationType("CHAT");
conversation.setMessageCount(0);
conversation.setStatus(0);
conversation.setCreateTime(LocalDateTime.now());
conversation.setUpdateTime(LocalDateTime.now());
conversationMapper.insert(conversation);
log.info("创建会话成功,用户: {}, 会话: {}", userId, conversation.getId());
return Result.success("创建会话成功", conversation);
} catch (Exception e) {
log.error("创建会话失败: {}", e.getMessage(), e);
return Result.error("创建会话失败: " + e.getMessage());
}
}
@Override
public Result<IPage<Conversation>> getUserConversations(String userId, int page, int size) {
try {
Page<Conversation> pageParam = new Page<>(page, size);
IPage<Conversation> conversations = conversationMapper.selectUserConversations(pageParam, userId);
return Result.success("获取会话列表成功", conversations);
} catch (Exception e) {
log.error("获取会话列表失败: {}", e.getMessage(), e);
return Result.error("获取会话列表失败: " + e.getMessage());
}
}
@Override
public Result<IPage<Message>> getConversationMessages(String conversationId, int page, int size) {
try {
Page<Message> pageParam = new Page<>(page, size);
IPage<Message> messages = messageMapper.selectConversationMessages(pageParam, conversationId);
return Result.success("获取消息列表成功", messages);
} catch (Exception e) {
log.error("获取消息列表失败: {}", e.getMessage(), e);
return Result.error("获取消息列表失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> deleteConversation(String userId, String conversationId) {
try {
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
if (!conversation.getUserId().equals(userId)) {
return Result.error("无权删除此会话");
}
// 删除会话下的所有消息
QueryWrapper<Message> messageWrapper = new QueryWrapper<>();
messageWrapper.eq("conversation_id", conversationId);
messageMapper.delete(messageWrapper);
// 删除会话
conversationMapper.deleteById(conversationId);
log.info("删除会话成功,用户: {}, 会话: {}", userId, conversationId);
return Result.success("删除会话成功");
} catch (Exception e) {
log.error("删除会话失败: {}", e.getMessage(), e);
return Result.error("删除会话失败: " + e.getMessage());
}
}
@Override
public Result<Conversation> getConversationById(String conversationId) {
try {
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
return Result.success("获取会话详情成功", conversation);
} catch (Exception e) {
log.error("获取会话详情失败: {}", e.getMessage(), e);
return Result.error("获取会话详情失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> clearConversation(String userId, String conversationId) {
try {
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
if (!conversation.getUserId().equals(userId)) {
return Result.error("无权清空此会话");
}
// 删除会话下的所有消息
QueryWrapper<Message> messageWrapper = new QueryWrapper<>();
messageWrapper.eq("conversation_id", conversationId);
messageMapper.delete(messageWrapper);
// 重置会话消息数量
conversation.setMessageCount(0);
conversation.setUpdateTime(LocalDateTime.now());
conversationMapper.updateById(conversation);
log.info("清空会话成功,用户: {}, 会话: {}", userId, conversationId);
return Result.success("清空会话成功");
} catch (Exception e) {
log.error("清空会话失败: {}", e.getMessage(), e);
return Result.error("清空会话失败: " + e.getMessage());
}
}
/**
* 获取AI回复
*/
private String getAiReply(String userId, String content, String conversationId) {
try {
Result<String> result = cozeApiService.sendMessageWithContext(content, userId, conversationId);
if (result.getCode() == 200) {
return result.getData();
} else {
log.error("AI回复失败: {}", result.getMessage());
return "抱歉,我现在无法回复,请稍后再试。";
}
} catch (Exception e) {
log.error("获取AI回复时发生错误: {}", e.getMessage(), e);
return "抱歉,系统出现错误,请稍后再试。";
}
}
}
@@ -0,0 +1,238 @@
package com.emotionmuseum.service.impl;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.auth.LoginRequest;
import com.emotionmuseum.dto.auth.LoginResponse;
import com.emotionmuseum.dto.auth.RegisterRequest;
import com.emotionmuseum.entity.User;
import com.emotionmuseum.mapper.UserMapper;
import com.emotionmuseum.service.AuthService;
import com.emotionmuseum.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public Result<LoginResponse> login(LoginRequest request) {
try {
// 参数验证
if (!StringUtils.hasText(request.getUsername()) || !StringUtils.hasText(request.getPassword())) {
return Result.error("用户名和密码不能为空");
}
// 查找用户
User user = userMapper.findByUsername(request.getUsername());
if (user == null) {
user = userMapper.findByEmail(request.getUsername());
}
if (user == null) {
return Result.error("用户不存在");
}
// 验证密码
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
return Result.error("密码错误");
}
// 检查用户状态
if (user.getStatus() != 1) {
return Result.error("用户已被禁用");
}
// 生成令牌
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
String refreshToken = jwtUtil.generateToken(user.getId(), user.getUsername());
// 更新最后登录时间
user.setLastLoginTime(LocalDateTime.now());
userMapper.updateById(user);
// 构建响应
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(86400L); // 24小时
LoginResponse.UserInfo userInfo = new LoginResponse.UserInfo();
userInfo.setId(user.getId());
userInfo.setUsername(user.getUsername());
userInfo.setNickname(user.getNickname());
userInfo.setEmail(user.getEmail());
userInfo.setAvatar(user.getAvatar());
userInfo.setUserType(user.getUserType());
response.setUserInfo(userInfo);
// 将令牌存储到Redis
String tokenKey = "token:" + user.getId();
redisTemplate.opsForValue().set(tokenKey, accessToken, 24, TimeUnit.HOURS);
log.info("用户登录成功: {}", user.getUsername());
return Result.success("登录成功", response);
} catch (Exception e) {
log.error("用户登录失败: {}", e.getMessage(), e);
return Result.error("登录失败,请稍后重试");
}
}
@Override
public Result<String> register(RegisterRequest request) {
try {
// 参数验证
if (!StringUtils.hasText(request.getUsername()) || !StringUtils.hasText(request.getPassword())) {
return Result.error("用户名和密码不能为空");
}
if (!request.getPassword().equals(request.getConfirmPassword())) {
return Result.error("两次输入的密码不一致");
}
// 检查用户名是否已存在
if (userMapper.countByUsername(request.getUsername()) > 0) {
return Result.error("用户名已存在");
}
// 检查邮箱是否已存在
if (StringUtils.hasText(request.getEmail()) && userMapper.countByEmail(request.getEmail()) > 0) {
return Result.error("邮箱已被注册");
}
// 检查手机号是否已存在
if (StringUtils.hasText(request.getPhone()) && userMapper.countByPhone(request.getPhone()) > 0) {
return Result.error("手机号已被注册");
}
// 创建用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEmail(request.getEmail());
user.setPhone(request.getPhone());
user.setNickname(StringUtils.hasText(request.getNickname()) ? request.getNickname() : request.getUsername());
user.setStatus(1);
user.setUserType(0);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userMapper.insert(user);
log.info("用户注册成功: {}", user.getUsername());
return Result.success("注册成功");
} catch (Exception e) {
log.error("用户注册失败: {}", e.getMessage(), e);
return Result.error("注册失败,请稍后重试");
}
}
@Override
public Result<String> logout(String token) {
try {
if (StringUtils.hasText(token)) {
String userId = jwtUtil.getUserIdFromToken(token);
if (StringUtils.hasText(userId)) {
// 从Redis中删除令牌
String tokenKey = "token:" + userId;
redisTemplate.delete(tokenKey);
}
}
return Result.success("登出成功");
} catch (Exception e) {
log.error("用户登出失败: {}", e.getMessage(), e);
return Result.error("登出失败");
}
}
@Override
public Result<String> refreshToken(String refreshToken) {
try {
if (!StringUtils.hasText(refreshToken)) {
return Result.error("刷新令牌不能为空");
}
if (!jwtUtil.validateToken(refreshToken)) {
return Result.error("刷新令牌无效或已过期");
}
String userId = jwtUtil.getUserIdFromToken(refreshToken);
String username = jwtUtil.getUsernameFromToken(refreshToken);
// 生成新的访问令牌
String newAccessToken = jwtUtil.generateToken(userId, username);
// 更新Redis中的令牌
String tokenKey = "token:" + userId;
redisTemplate.opsForValue().set(tokenKey, newAccessToken, 24, TimeUnit.HOURS);
return Result.success("令牌刷新成功", newAccessToken);
} catch (Exception e) {
log.error("刷新令牌失败: {}", e.getMessage(), e);
return Result.error("刷新令牌失败");
}
}
@Override
public boolean validateToken(String token) {
if (!StringUtils.hasText(token)) {
return false;
}
try {
// 验证JWT令牌
if (!jwtUtil.validateToken(token)) {
return false;
}
// 检查Redis中是否存在令牌
String userId = jwtUtil.getUserIdFromToken(token);
String tokenKey = "token:" + userId;
String storedToken = (String) redisTemplate.opsForValue().get(tokenKey);
return token.equals(storedToken);
} catch (Exception e) {
log.error("验证令牌失败: {}", e.getMessage(), e);
return false;
}
}
@Override
public String getUserIdFromToken(String token) {
try {
return jwtUtil.getUserIdFromToken(token);
} catch (Exception e) {
log.error("从令牌中获取用户ID失败: {}", e.getMessage(), e);
return null;
}
}
}
@@ -0,0 +1,229 @@
package com.emotionmuseum.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.comment.CommentRequest;
import com.emotionmuseum.dto.comment.CommentResponse;
import com.emotionmuseum.entity.Comment;
import com.emotionmuseum.mapper.CommentMapper;
import com.emotionmuseum.service.CommentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 评论服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class CommentServiceImpl implements CommentService {
@Autowired
private CommentMapper commentMapper;
@Override
@Transactional
public Result<CommentResponse> createComment(String userId, CommentRequest request) {
try {
Comment comment = new Comment();
comment.setParentId(request.getParentId());
comment.setContentType(request.getContentType());
comment.setContentId(request.getContentId());
comment.setUserId(userId);
comment.setContent(request.getContent());
comment.setLikeCount(0);
comment.setReplyCount(0);
comment.setStatus(1);
comment.setCreateTime(LocalDateTime.now());
comment.setUpdateTime(LocalDateTime.now());
commentMapper.insert(comment);
CommentResponse response = buildCommentResponse(comment);
log.info("创建评论成功,用户: {}, 评论: {}", userId, comment.getId());
return Result.success("创建评论成功", response);
} catch (Exception e) {
log.error("创建评论失败: {}", e.getMessage(), e);
return Result.error("创建评论失败: " + e.getMessage());
}
}
@Override
public Result<IPage<CommentResponse>> getContentComments(String contentType, String contentId, int page, int size) {
try {
Page<Comment> pageParam = new Page<>(page, size);
IPage<Comment> comments = commentMapper.selectContentComments(pageParam, contentType, contentId);
IPage<CommentResponse> responsePage = new Page<>(page, size);
responsePage.setTotal(comments.getTotal());
responsePage.setPages(comments.getPages());
responsePage.setCurrent(comments.getCurrent());
responsePage.setSize(comments.getSize());
List<CommentResponse> responses = comments.getRecords().stream()
.map(this::buildCommentResponse)
.collect(Collectors.toList());
responsePage.setRecords(responses);
return Result.success("获取评论列表成功", responsePage);
} catch (Exception e) {
log.error("获取评论列表失败: {}", e.getMessage(), e);
return Result.error("获取评论列表失败: " + e.getMessage());
}
}
@Override
public Result<CommentResponse> getCommentById(String commentId) {
try {
Comment comment = commentMapper.selectById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
CommentResponse response = buildCommentResponse(comment);
return Result.success("获取评论详情成功", response);
} catch (Exception e) {
log.error("获取评论详情失败: {}", e.getMessage(), e);
return Result.error("获取评论详情失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> deleteComment(String userId, String commentId) {
try {
Comment comment = commentMapper.selectById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
if (!comment.getUserId().equals(userId)) {
return Result.error("无权删除此评论");
}
commentMapper.deleteById(commentId);
log.info("删除评论成功,用户: {}, 评论: {}", userId, commentId);
return Result.success("删除评论成功");
} catch (Exception e) {
log.error("删除评论失败: {}", e.getMessage(), e);
return Result.error("删除评论失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> likeComment(String userId, String commentId) {
try {
Comment comment = commentMapper.selectById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
comment.setLikeCount(comment.getLikeCount() + 1);
comment.setUpdateTime(LocalDateTime.now());
commentMapper.updateById(comment);
log.info("点赞评论成功,用户: {}, 评论: {}", userId, commentId);
return Result.success("点赞成功");
} catch (Exception e) {
log.error("点赞评论失败: {}", e.getMessage(), e);
return Result.error("点赞失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> unlikeComment(String userId, String commentId) {
try {
Comment comment = commentMapper.selectById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
if (comment.getLikeCount() > 0) {
comment.setLikeCount(comment.getLikeCount() - 1);
comment.setUpdateTime(LocalDateTime.now());
commentMapper.updateById(comment);
}
log.info("取消点赞成功,用户: {}, 评论: {}", userId, commentId);
return Result.success("取消点赞成功");
} catch (Exception e) {
log.error("取消点赞失败: {}", e.getMessage(), e);
return Result.error("取消点赞失败: " + e.getMessage());
}
}
@Override
public Result<IPage<CommentResponse>> getUserComments(String userId, int page, int size) {
try {
Page<Comment> pageParam = new Page<>(page, size);
IPage<Comment> comments = commentMapper.selectPage(pageParam, null);
IPage<CommentResponse> responsePage = new Page<>(page, size);
responsePage.setTotal(comments.getTotal());
responsePage.setPages(comments.getPages());
responsePage.setCurrent(comments.getCurrent());
responsePage.setSize(comments.getSize());
List<CommentResponse> responses = comments.getRecords().stream()
.map(this::buildCommentResponse)
.collect(Collectors.toList());
responsePage.setRecords(responses);
return Result.success("获取用户评论列表成功", responsePage);
} catch (Exception e) {
log.error("获取用户评论列表失败: {}", e.getMessage(), e);
return Result.error("获取用户评论列表失败: " + e.getMessage());
}
}
@Override
public Result<List<CommentResponse>> getCommentReplies(String commentId) {
try {
List<Comment> replies = commentMapper.selectCommentReplies(commentId);
List<CommentResponse> responses = replies.stream()
.map(this::buildCommentResponse)
.collect(Collectors.toList());
return Result.success("获取评论回复列表成功", responses);
} catch (Exception e) {
log.error("获取评论回复列表失败: {}", e.getMessage(), e);
return Result.error("获取评论回复列表失败: " + e.getMessage());
}
}
/**
* 构建评论响应DTO
*/
private CommentResponse buildCommentResponse(Comment comment) {
CommentResponse response = new CommentResponse();
response.setId(comment.getId());
response.setParentId(comment.getParentId());
response.setContentType(comment.getContentType());
response.setContentId(comment.getContentId());
response.setUserId(comment.getUserId());
response.setContent(comment.getContent());
response.setLikeCount(comment.getLikeCount());
response.setReplyCount(comment.getReplyCount());
response.setStatus(comment.getStatus());
response.setCreateTime(comment.getCreateTime());
response.setIsLiked(false);
response.setReplies(new ArrayList<>());
return response;
}
}
@@ -0,0 +1,203 @@
package com.emotionmuseum.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.emotionmuseum.config.CozeConfig;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.service.CozeApiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* Coze API服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class CozeApiServiceImpl implements CozeApiService {
@Autowired
private CozeConfig cozeConfig;
@Autowired
private WebClient.Builder webClientBuilder;
@Override
public Result<String> sendMessage(String message, String userId) {
try {
if (!StrUtil.isNotBlank(cozeConfig.getApiKey()) || !StrUtil.isNotBlank(cozeConfig.getBotId())) {
return Result.error("Coze API配置不完整");
}
if (!StrUtil.isNotBlank(message)) {
return Result.error("消息内容不能为空");
}
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("bot_id", cozeConfig.getBotId());
requestBody.put("user_id", userId);
requestBody.put("query", message);
requestBody.put("stream", false);
String response = webClientBuilder.build()
.post()
.uri(cozeConfig.getBaseUrl() + "/bot/chat")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + cozeConfig.getApiKey())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofMillis(cozeConfig.getTimeout()))
.block();
if (StrUtil.isNotBlank(response)) {
JSONObject jsonResponse = JSONUtil.parseObj(response);
if (jsonResponse.getInt("code", -1) == 0) {
JSONObject data = jsonResponse.getJSONObject("data");
String reply = data.getStr("reply");
log.info("Coze API调用成功,用户: {}, 消息: {}", userId, message);
return Result.success("AI回复成功", reply);
} else {
String errorMsg = jsonResponse.getStr("message", "未知错误");
log.error("Coze API调用失败: {}", errorMsg);
return Result.error("AI回复失败: " + errorMsg);
}
} else {
log.error("Coze API返回空响应");
return Result.error("AI回复失败: 空响应");
}
} catch (Exception e) {
log.error("调用Coze API时发生错误: {}", e.getMessage(), e);
return Result.error("AI回复失败: " + e.getMessage());
}
}
@Override
public Result<String> sendMessageWithContext(String message, String userId, String conversationId) {
try {
if (!StrUtil.isNotBlank(cozeConfig.getApiKey()) || !StrUtil.isNotBlank(cozeConfig.getBotId())) {
return Result.error("Coze API配置不完整");
}
if (!StrUtil.isNotBlank(message)) {
return Result.error("消息内容不能为空");
}
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("bot_id", cozeConfig.getBotId());
requestBody.put("user_id", userId);
requestBody.put("query", message);
requestBody.put("conversation_id", conversationId);
requestBody.put("stream", false);
String response = webClientBuilder.build()
.post()
.uri(cozeConfig.getBaseUrl() + "/bot/chat")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + cozeConfig.getApiKey())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofMillis(cozeConfig.getTimeout()))
.block();
if (StrUtil.isNotBlank(response)) {
JSONObject jsonResponse = JSONUtil.parseObj(response);
if (jsonResponse.getInt("code", -1) == 0) {
JSONObject data = jsonResponse.getJSONObject("data");
String reply = data.getStr("reply");
log.info("Coze API调用成功,用户: {}, 会话: {}, 消息: {}", userId, conversationId, message);
return Result.success("AI回复成功", reply);
} else {
String errorMsg = jsonResponse.getStr("message", "未知错误");
log.error("Coze API调用失败: {}", errorMsg);
return Result.error("AI回复失败: " + errorMsg);
}
} else {
log.error("Coze API返回空响应");
return Result.error("AI回复失败: 空响应");
}
} catch (Exception e) {
log.error("调用Coze API时发生错误: {}", e.getMessage(), e);
return Result.error("AI回复失败: " + e.getMessage());
}
}
@Override
public Result<String> getBotInfo() {
try {
if (!StrUtil.isNotBlank(cozeConfig.getApiKey()) || !StrUtil.isNotBlank(cozeConfig.getBotId())) {
return Result.error("Coze API配置不完整");
}
String response = webClientBuilder.build()
.get()
.uri(cozeConfig.getBaseUrl() + "/bot/" + cozeConfig.getBotId())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + cozeConfig.getApiKey())
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofMillis(cozeConfig.getTimeout()))
.block();
if (StrUtil.isNotBlank(response)) {
JSONObject jsonResponse = JSONUtil.parseObj(response);
if (jsonResponse.getInt("code", -1) == 0) {
JSONObject data = jsonResponse.getJSONObject("data");
log.info("获取Bot信息成功");
return Result.success("获取Bot信息成功", data.toString());
} else {
String errorMsg = jsonResponse.getStr("message", "未知错误");
log.error("获取Bot信息失败: {}", errorMsg);
return Result.error("获取Bot信息失败: " + errorMsg);
}
} else {
log.error("获取Bot信息返回空响应");
return Result.error("获取Bot信息失败: 空响应");
}
} catch (Exception e) {
log.error("获取Bot信息时发生错误: {}", e.getMessage(), e);
return Result.error("获取Bot信息失败: " + e.getMessage());
}
}
@Override
public Result<Boolean> checkConnection() {
try {
if (!StrUtil.isNotBlank(cozeConfig.getApiKey()) || !StrUtil.isNotBlank(cozeConfig.getBotId())) {
return Result.error("Coze API配置不完整");
}
// 尝试获取Bot信息来检查连接
Result<String> botInfoResult = getBotInfo();
if (botInfoResult.getCode() == 200) {
log.info("Coze API连接正常");
return Result.success("连接正常", true);
} else {
log.error("Coze API连接失败: {}", botInfoResult.getMessage());
return Result.error("连接失败: " + botInfoResult.getMessage());
}
} catch (Exception e) {
log.error("检查Coze API连接时发生错误: {}", e.getMessage(), e);
return Result.error("连接检查失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,282 @@
package com.emotionmuseum.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.dto.diary.DiaryPostRequest;
import com.emotionmuseum.entity.DiaryPost;
import com.emotionmuseum.mapper.DiaryPostMapper;
import com.emotionmuseum.service.CozeApiService;
import com.emotionmuseum.service.DiaryPostService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 日记服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class DiaryPostServiceImpl implements DiaryPostService {
@Autowired
private DiaryPostMapper diaryPostMapper;
@Autowired
private CozeApiService cozeApiService;
@Override
@Transactional
public Result<DiaryPost> createDiary(String userId, DiaryPostRequest request) {
try {
DiaryPost diaryPost = new DiaryPost();
diaryPost.setUserId(userId);
diaryPost.setTitle(request.getTitle());
diaryPost.setContent(request.getContent());
diaryPost.setEmotionTags(request.getEmotionTags());
diaryPost.setEmotionScore(request.getEmotionScore());
diaryPost.setWeather(request.getWeather());
diaryPost.setLocation(request.getLocation());
diaryPost.setImages(request.getImages());
diaryPost.setIsPublic(request.getIsPublic());
diaryPost.setLikeCount(0);
diaryPost.setCommentCount(0);
diaryPost.setShareCount(0);
diaryPost.setStatus(1); // 已发布
diaryPost.setCreateTime(LocalDateTime.now());
diaryPost.setUpdateTime(LocalDateTime.now());
diaryPostMapper.insert(diaryPost);
// 异步生成AI点评
generateAiComment(diaryPost);
log.info("创建日记成功,用户: {}, 日记: {}", userId, diaryPost.getId());
return Result.success("创建日记成功", diaryPost);
} catch (Exception e) {
log.error("创建日记失败: {}", e.getMessage(), e);
return Result.error("创建日记失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> updateDiary(String userId, String diaryId, DiaryPostRequest request) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
if (!diaryPost.getUserId().equals(userId)) {
return Result.error("无权修改此日记");
}
diaryPost.setTitle(request.getTitle());
diaryPost.setContent(request.getContent());
diaryPost.setEmotionTags(request.getEmotionTags());
diaryPost.setEmotionScore(request.getEmotionScore());
diaryPost.setWeather(request.getWeather());
diaryPost.setLocation(request.getLocation());
diaryPost.setImages(request.getImages());
diaryPost.setIsPublic(request.getIsPublic());
diaryPost.setUpdateTime(LocalDateTime.now());
diaryPostMapper.updateById(diaryPost);
log.info("更新日记成功,用户: {}, 日记: {}", userId, diaryId);
return Result.success("更新日记成功");
} catch (Exception e) {
log.error("更新日记失败: {}", e.getMessage(), e);
return Result.error("更新日记失败: " + e.getMessage());
}
}
@Override
public Result<DiaryPost> getDiaryById(String diaryId) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
return Result.success("获取日记详情成功", diaryPost);
} catch (Exception e) {
log.error("获取日记详情失败: {}", e.getMessage(), e);
return Result.error("获取日记详情失败: " + e.getMessage());
}
}
@Override
public Result<IPage<DiaryPost>> getUserDiaries(String userId, int page, int size) {
try {
Page<DiaryPost> pageParam = new Page<>(page, size);
IPage<DiaryPost> diaries = diaryPostMapper.selectUserDiaries(pageParam, userId);
return Result.success("获取用户日记列表成功", diaries);
} catch (Exception e) {
log.error("获取用户日记列表失败: {}", e.getMessage(), e);
return Result.error("获取用户日记列表失败: " + e.getMessage());
}
}
@Override
public Result<IPage<DiaryPost>> getPublicDiaries(int page, int size) {
try {
Page<DiaryPost> pageParam = new Page<>(page, size);
IPage<DiaryPost> diaries = diaryPostMapper.selectPublicDiaries(pageParam);
return Result.success("获取公开日记列表成功", diaries);
} catch (Exception e) {
log.error("获取公开日记列表失败: {}", e.getMessage(), e);
return Result.error("获取公开日记列表失败: " + e.getMessage());
}
}
@Override
public Result<IPage<DiaryPost>> getDiariesByEmotionTag(String emotionTag, int page, int size) {
try {
Page<DiaryPost> pageParam = new Page<>(page, size);
IPage<DiaryPost> diaries = diaryPostMapper.selectDiariesByEmotionTag(pageParam, emotionTag);
return Result.success("获取情绪标签日记列表成功", diaries);
} catch (Exception e) {
log.error("获取情绪标签日记列表失败: {}", e.getMessage(), e);
return Result.error("获取情绪标签日记列表失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> deleteDiary(String userId, String diaryId) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
if (!diaryPost.getUserId().equals(userId)) {
return Result.error("无权删除此日记");
}
diaryPostMapper.deleteById(diaryId);
log.info("删除日记成功,用户: {}, 日记: {}", userId, diaryId);
return Result.success("删除日记成功");
} catch (Exception e) {
log.error("删除日记失败: {}", e.getMessage(), e);
return Result.error("删除日记失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> likeDiary(String userId, String diaryId) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
diaryPost.setLikeCount(diaryPost.getLikeCount() + 1);
diaryPost.setUpdateTime(LocalDateTime.now());
diaryPostMapper.updateById(diaryPost);
log.info("点赞日记成功,用户: {}, 日记: {}", userId, diaryId);
return Result.success("点赞成功");
} catch (Exception e) {
log.error("点赞日记失败: {}", e.getMessage(), e);
return Result.error("点赞失败: " + e.getMessage());
}
}
@Override
@Transactional
public Result<String> unlikeDiary(String userId, String diaryId) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
if (diaryPost.getLikeCount() > 0) {
diaryPost.setLikeCount(diaryPost.getLikeCount() - 1);
diaryPost.setUpdateTime(LocalDateTime.now());
diaryPostMapper.updateById(diaryPost);
}
log.info("取消点赞成功,用户: {}, 日记: {}", userId, diaryId);
return Result.success("取消点赞成功");
} catch (Exception e) {
log.error("取消点赞失败: {}", e.getMessage(), e);
return Result.error("取消点赞失败: " + e.getMessage());
}
}
@Override
public Result<String> getAiComment(String userId, String diaryId) {
try {
DiaryPost diaryPost = diaryPostMapper.selectById(diaryId);
if (diaryPost == null) {
return Result.error("日记不存在");
}
if (StringUtils.hasText(diaryPost.getAiComment())) {
return Result.success("获取AI点评成功", diaryPost.getAiComment());
}
// 生成AI点评
String aiComment = generateAiComment(diaryPost);
return Result.success("获取AI点评成功", aiComment);
} catch (Exception e) {
log.error("获取AI点评失败: {}", e.getMessage(), e);
return Result.error("获取AI点评失败: " + e.getMessage());
}
}
/**
* 生成AI点评
*/
private String generateAiComment(DiaryPost diaryPost) {
try {
String prompt = String.format(
"请对以下日记进行情感分析和点评,要求:\n" +
"1. 分析作者的情感状态\n" +
"2. 提供积极正面的建议\n" +
"3. 字数控制在200字以内\n" +
"4. 语言温暖友善\n\n" +
"日记标题:%s\n" +
"日记内容:%s\n" +
"情绪标签:%s\n" +
"情绪评分:%d/10",
diaryPost.getTitle(),
diaryPost.getContent(),
diaryPost.getEmotionTags(),
diaryPost.getEmotionScore()
);
Result<String> result = cozeApiService.sendMessage(prompt, diaryPost.getUserId());
if (result.getCode() == 200) {
String aiComment = result.getData();
diaryPost.setAiComment(aiComment);
diaryPost.setUpdateTime(LocalDateTime.now());
diaryPostMapper.updateById(diaryPost);
return aiComment;
} else {
log.error("生成AI点评失败: {}", result.getMessage());
return "AI正在思考中,请稍后再试。";
}
} catch (Exception e) {
log.error("生成AI点评时发生错误: {}", e.getMessage(), e);
return "AI点评生成失败,请稍后再试。";
}
}
}
@@ -0,0 +1,175 @@
package com.emotionmuseum.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.emotionmuseum.dto.Result;
import com.emotionmuseum.entity.User;
import com.emotionmuseum.mapper.UserMapper;
import com.emotionmuseum.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 用户服务实现类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Result<User> getUserById(String userId) {
try {
User user = userMapper.selectById(userId);
if (user == null) {
return Result.error("用户不存在");
}
// 清除敏感信息
user.setPassword(null);
return Result.success("获取用户信息成功", user);
} catch (Exception e) {
log.error("获取用户信息失败: {}", e.getMessage(), e);
return Result.error("获取用户信息失败");
}
}
@Override
public Result<String> updateUser(String userId, User user) {
try {
User existingUser = userMapper.selectById(userId);
if (existingUser == null) {
return Result.error("用户不存在");
}
// 只允许更新特定字段
if (StringUtils.hasText(user.getNickname())) {
existingUser.setNickname(user.getNickname());
}
if (StringUtils.hasText(user.getEmail())) {
existingUser.setEmail(user.getEmail());
}
if (StringUtils.hasText(user.getPhone())) {
existingUser.setPhone(user.getPhone());
}
if (StringUtils.hasText(user.getAvatar())) {
existingUser.setAvatar(user.getAvatar());
}
if (StringUtils.hasText(user.getBio())) {
existingUser.setBio(user.getBio());
}
if (user.getGender() != null) {
existingUser.setGender(user.getGender());
}
if (user.getBirthday() != null) {
existingUser.setBirthday(user.getBirthday());
}
existingUser.setUpdateTime(LocalDateTime.now());
userMapper.updateById(existingUser);
log.info("用户信息更新成功: {}", userId);
return Result.success("用户信息更新成功");
} catch (Exception e) {
log.error("更新用户信息失败: {}", e.getMessage(), e);
return Result.error("更新用户信息失败");
}
}
@Override
public Result<String> changePassword(String userId, String oldPassword, String newPassword) {
try {
User user = userMapper.selectById(userId);
if (user == null) {
return Result.error("用户不存在");
}
// 验证旧密码
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
return Result.error("原密码错误");
}
// 更新密码
user.setPassword(passwordEncoder.encode(newPassword));
user.setUpdateTime(LocalDateTime.now());
userMapper.updateById(user);
log.info("用户密码修改成功: {}", userId);
return Result.success("密码修改成功");
} catch (Exception e) {
log.error("修改密码失败: {}", e.getMessage(), e);
return Result.error("修改密码失败");
}
}
@Override
public Result<IPage<User>> getUserList(int page, int size) {
try {
Page<User> pageParam = new Page<>(page, size);
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("create_time");
IPage<User> userPage = userMapper.selectPage(pageParam, queryWrapper);
// 清除敏感信息
userPage.getRecords().forEach(user -> user.setPassword(null));
return Result.success("获取用户列表成功", userPage);
} catch (Exception e) {
log.error("获取用户列表失败: {}", e.getMessage(), e);
return Result.error("获取用户列表失败");
}
}
@Override
public Result<String> deleteUser(String userId) {
try {
User user = userMapper.selectById(userId);
if (user == null) {
return Result.error("用户不存在");
}
userMapper.deleteById(userId);
log.info("用户删除成功: {}", userId);
return Result.success("用户删除成功");
} catch (Exception e) {
log.error("删除用户失败: {}", e.getMessage(), e);
return Result.error("删除用户失败");
}
}
@Override
public Result<String> toggleUserStatus(String userId, Integer status) {
try {
User user = userMapper.selectById(userId);
if (user == null) {
return Result.error("用户不存在");
}
user.setStatus(status);
user.setUpdateTime(LocalDateTime.now());
userMapper.updateById(user);
String message = status == 1 ? "用户启用成功" : "用户禁用成功";
log.info("用户状态更新成功: {} -> {}", userId, status);
return Result.success(message);
} catch (Exception e) {
log.error("更新用户状态失败: {}", e.getMessage(), e);
return Result.error("更新用户状态失败");
}
}
}
@@ -0,0 +1,153 @@
package com.emotionmuseum.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT工具类
*
* @author emotion-museum
* @version 1.0.0
* @since 2024-01-01
*/
@Component
@Slf4j
public class JwtUtil {
@Value("${emotion.jwt.secret}")
private String secret;
@Value("${emotion.jwt.expiration}")
private Long expiration;
/**
* 生成JWT令牌
*/
public String generateToken(String userId, String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
return createToken(claims, userId);
}
/**
* 创建令牌
*/
private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiryDate)
.signWith(key, Jwts.SIG.HS512)
.compact();
}
/**
* 从令牌中获取用户ID
*/
public String getUserIdFromToken(String token) {
return getClaimFromToken(token, "userId", String.class);
}
/**
* 从令牌中获取用户名
*/
public String getUsernameFromToken(String token) {
return getClaimFromToken(token, "username", String.class);
}
/**
* 从令牌中获取过期时间
*/
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
/**
* 从令牌中获取指定声明
*/
public <T> T getClaimFromToken(String token, String claimName, Class<T> requiredType) {
final Claims claims = getAllClaimsFromToken(token);
return claims.get(claimName, requiredType);
}
/**
* 从令牌中获取指定声明
*/
public <T> T getClaimFromToken(String token, java.util.function.Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
/**
* 从令牌中获取所有声明
*/
private Claims getAllClaimsFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 检查令牌是否过期
*/
public Boolean isTokenExpired(String token) {
try {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
} catch (Exception e) {
log.error("检查令牌过期时发生错误: {}", e.getMessage());
return true;
}
}
/**
* 验证令牌
*/
public Boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token);
return !isTokenExpired(token);
} catch (JwtException | IllegalArgumentException e) {
log.error("验证令牌时发生错误: {}", e.getMessage());
return false;
}
}
/**
* 刷新令牌
*/
public String refreshToken(String token) {
try {
final Claims claims = getAllClaimsFromToken(token);
// 创建新的声明,因为Claims是不可变的
Map<String, Object> newClaims = new HashMap<>(claims);
newClaims.put("iat", new Date().getTime() / 1000);
return createToken(newClaims, claims.getSubject());
} catch (Exception e) {
log.error("刷新令牌时发生错误: {}", e.getMessage());
return null;
}
}
}
@@ -0,0 +1,79 @@
server:
port: 19089
servlet:
context-path: /api
spring:
datasource:
url: jdbc:mysql://localhost:3306/emotion_museum_local?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:password}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
connection-test-query: SELECT 1
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 0
timeout: 2000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 1000ms
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
cache-enabled: true
lazy-loading-enabled: true
aggressive-lazy-loading: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath:mapper/*.xml
# 日志配置
logging:
level:
com.emotionmuseum: DEBUG
org.springframework.security: WARN
org.springframework.web: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file:
name: logs/emotion-museum.log
max-size: 100MB
max-history: 30
# SpringDoc OpenAPI配置
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
info:
title: 情绪博物馆API文档
description: 情绪博物馆后端服务API文档
version: 1.0.0
contact:
name: 情绪博物馆团队
email: support@emotion-museum.com
+54
View File
@@ -0,0 +1,54 @@
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
application:
name: emotion-museum
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
serialization:
write-dates-as-timestamps: false
default-property-inclusion: non_null
# 管理端点配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
enabled: true
metrics:
export:
prometheus:
enabled: true
# 应用配置
emotion:
jwt:
secret: ${JWT_SECRET:emotion-museum-jwt-secret-key-2024}
expiration: 86400000 # 24小时
coze:
api-key: ${COZE_API_KEY:}
bot-id: ${COZE_BOT_ID:}
base-url: https://www.coze.cn/api
timeout: 30000
max-retries: 3
file:
upload-path: ${FILE_UPLOAD_PATH:./uploads}
max-size: 10485760 # 10MB
# 安全配置
security:
ignore-urls:
- /auth/**
- /health/**
- /actuator/**
- /ws/**
- /ai/guest/**
- /swagger-ui/**
- /v3/api-docs/**
- /favicon.ico
@@ -0,0 +1,856 @@
# 情绪博物馆后端功能模块技术规范说明
## 1. 项目概述
### 1.1 项目基本信息
- **项目名称**: 情绪博物馆后端服务 (emotion-single)
- **技术架构**: Spring Boot 2.7.18 单体架构
- **Java版本**: JDK 17
- **服务端口**: 19089
- **API前缀**: /api
- **项目类型**: 情感记录与AI对话平台
### 1.2 核心功能模块
- **用户认证系统**: 登录、注册、JWT认证
- **AI对话系统**: 基于Coze平台的智能对话
- **情绪日记系统**: 日记发布、AI点评、社交分享
- **WebSocket实时通信**: 实时聊天、消息推送
- **数据分析系统**: 情绪分析、用户统计
- **社区互动系统**: 评论、点赞、分享
- **成就奖励系统**: 用户成长、奖励机制
## 2. 技术架构设计
### 2.1 技术栈选型
#### 2.1.1 核心框架
```xml
<!-- Spring Boot 2.7.18 -->
<spring-boot.version>2.7.18</spring-boot.version>
<!-- 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
```
#### 2.1.2 数据存储
```xml
<!-- MySQL 8.0 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- MyBatis Plus 3.5.3.1 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
```
#### 2.1.3 安全认证
```xml
<!-- JWT 0.11.5 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
```
### 2.2 项目结构设计
```
src/main/java/com/emotion/
├── EmotionSimpleApplication.java # 启动类
├── config/ # 配置类
│ ├── AsyncConfig.java # 异步配置
│ ├── MybatisPlusConfig.java # MyBatis-Plus配置
│ ├── RedisConfig.java # Redis配置
│ ├── WebSocketConfig.java # WebSocket配置
│ ├── SecurityConfig.java # Spring Security配置
│ ├── IdGeneratorConfig.java # ID生成器配置
│ └── WebMvcConfig.java # Web MVC配置
├── controller/ # 控制器层 (24个控制器)
│ ├── AuthController.java # 认证控制器
│ ├── AiChatController.java # AI聊天控制器
│ ├── DiaryPostController.java # 日记控制器
│ ├── UserController.java # 用户控制器
│ ├── WebSocketController.java # WebSocket控制器
│ ├── MessageController.java # 消息控制器
│ ├── ConversationController.java # 会话控制器
│ ├── EmotionAnalysisController.java # 情绪分析控制器
│ ├── CommunityPostController.java # 社区帖子控制器
│ ├── CommentController.java # 评论控制器
│ ├── AchievementController.java # 成就控制器
│ ├── UserStatsController.java # 用户统计控制器
│ ├── RewardController.java # 奖励控制器
│ ├── GuestUserController.java # 访客用户控制器
│ ├── CozeApiCallController.java # Coze API调用控制器
│ ├── EmotionRecordController.java # 情绪记录控制器
│ ├── TopicInteractionController.java # 话题互动控制器
│ ├── GrowthTopicController.java # 成长话题控制器
│ ├── DiaryCommentController.java # 日记评论控制器
│ ├── EmotionSummaryController.java # 情绪总结控制器
│ ├── TokenController.java # Token控制器
│ ├── ChatWebSocketController.java # 聊天WebSocket控制器
│ └── HealthController.java # 健康检查控制器
├── service/ # 服务层 (20个服务)
│ ├── AuthService.java # 认证服务
│ ├── AiChatService.java # AI聊天服务
│ ├── DiaryPostService.java # 日记服务
│ ├── UserService.java # 用户服务
│ ├── WebSocketService.java # WebSocket服务
│ ├── MessageService.java # 消息服务
│ ├── ConversationService.java # 会话服务
│ ├── EmotionAnalysisService.java # 情绪分析服务
│ ├── CommunityPostService.java # 社区帖子服务
│ ├── CommentService.java # 评论服务
│ ├── AchievementService.java # 成就服务
│ ├── UserStatsService.java # 用户统计服务
│ ├── RewardService.java # 奖励服务
│ ├── GuestUserService.java # 访客用户服务
│ ├── CozeApiCallService.java # Coze API调用服务
│ ├── EmotionRecordService.java # 情绪记录服务
│ ├── TopicInteractionService.java # 话题互动服务
│ ├── GrowthTopicService.java # 成长话题服务
│ ├── DiaryCommentService.java # 日记评论服务
│ └── TokenService.java # Token服务
├── mapper/ # 数据访问层
├── entity/ # 实体类 (19个实体)
│ ├── User.java # 用户实体
│ ├── DiaryPost.java # 日记实体
│ ├── Message.java # 消息实体
│ ├── Conversation.java # 会话实体
│ ├── Comment.java # 评论实体
│ ├── CommunityPost.java # 社区帖子实体
│ ├── Achievement.java # 成就实体
│ ├── Reward.java # 奖励实体
│ ├── GuestUser.java # 访客用户实体
│ ├── EmotionRecord.java # 情绪记录实体
│ ├── EmotionAnalysis.java # 情绪分析实体
│ ├── UserStats.java # 用户统计实体
│ ├── GrowthTopic.java # 成长话题实体
│ ├── TopicInteraction.java # 话题互动实体
│ ├── LocationPin.java # 位置标记实体
│ ├── DiaryComment.java # 日记评论实体
│ └── CozeApiCall.java # Coze API调用记录实体
├── dto/ # 数据传输对象
│ ├── request/ # 请求对象
│ └── response/ # 响应对象
├── common/ # 公共组件
│ ├── BaseEntity.java # 基础实体
│ ├── BasePageRequest.java # 基础分页请求
│ ├── PageResult.java # 分页结果
│ └── Result.java # 统一返回结果
├── config/ # 配置类
├── interceptor/ # 拦截器
├── handler/ # 处理器
├── exception/ # 异常处理
└── util/ # 工具类
```
## 3. 核心功能模块详解
### 3.1 用户认证系统 (AuthController)
#### 3.1.1 功能概述
提供完整的用户认证服务,包括登录、注册、Token管理、验证码等功能。
#### 3.1.2 核心接口
```java
@RestController
@RequestMapping("/auth")
public class AuthController {
// 用户登录
@PostMapping("/login")
public Result<AuthResponse> login(@Valid @RequestBody LoginRequest request)
// 用户注册
@PostMapping("/register")
public Result<AuthResponse> register(@Valid @RequestBody RegisterRequest request)
// 获取当前用户信息
@GetMapping("/user/info")
public Result<UserInfoResponse> getCurrentUserInfo(HttpServletRequest request)
// 生成验证码
@GetMapping("/captcha")
public Result<CaptchaResponse> generateCaptcha()
// 用户登出
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request)
// 刷新访问令牌
@PostMapping("/refresh")
public Result<AuthResponse> refreshToken(@Valid @RequestBody RefreshTokenRequest request)
// 验证访问令牌
@GetMapping("/validate")
public Result<Boolean> validateToken(HttpServletRequest request)
// 检查账号是否存在
@GetMapping("/check-account")
public Result<Boolean> checkAccount(@RequestParam String account)
// 检查邮箱是否存在
@GetMapping("/check-email")
public Result<Boolean> checkEmail(@RequestParam String email)
// 检查手机号是否存在
@GetMapping("/check-phone")
public Result<Boolean> checkPhone(@RequestParam String phone)
}
```
#### 3.1.3 技术特点
- **JWT认证**: 使用JWT进行无状态认证
- **验证码支持**: 图形验证码生成和验证
- **Token刷新**: 支持访问令牌自动刷新
- **参数校验**: 使用@Valid进行请求参数校验
- **统一返回**: 使用Result<T>统一返回格式
### 3.2 AI对话系统 (AiChatController)
#### 3.2.1 功能概述
基于Coze平台的AI对话服务,支持智能聊天、对话总结、访客聊天等功能。
#### 3.2.2 核心接口
```java
@RestController
@RequestMapping("/ai")
public class AiChatController {
// 发送聊天消息
@PostMapping("/chat")
public Result<AiChatResponse> sendChatMessage(@Valid @RequestBody AiChatRequest request)
// 生成对话总结
@PostMapping("/summary")
public Result<AiSummaryResponse> generateSummary(@Valid @RequestBody AiSummaryRequest request)
// 获取AI服务状态
@GetMapping("/status")
public Result<AiStatusResponse> getServiceStatus()
// 获取聊天统计
@GetMapping("/stats")
public Result<ChatStatsResponse> getChatStats(@RequestParam(required = false) String userId,
@RequestParam(required = false) String conversationId)
// 访客聊天
@PostMapping("/guest/chat")
public Result<GuestChatResponse> guestChat(@Valid @RequestBody GuestChatRequest request,
HttpServletRequest httpRequest)
// 获取访客用户信息
@GetMapping("/guest/user/info")
public Result<GuestUserInfoResponse> getGuestUserInfo(HttpServletRequest request)
// 创建会话
@PostMapping("/conversation/create")
public Result<ConversationResponse> createConversation(@Valid @RequestBody ConversationCreateRequest request,
HttpServletRequest httpRequest)
}
```
#### 3.2.3 技术特点
- **Coze集成**: 集成Coze AI平台进行智能对话
- **会话管理**: 支持多会话管理和历史记录
- **访客支持**: 支持未登录用户的AI对话
- **异步处理**: 支持异步AI调用和响应
- **统计分析**: 提供聊天数据统计分析
### 3.3 情绪日记系统 (DiaryPostController)
#### 3.3.1 功能概述
用户情绪日记的发布、管理、AI点评、社交分享等完整功能。
#### 3.3.2 核心接口
```java
@RestController
@RequestMapping("/diary-post")
public class DiaryPostController {
// 分页查询日记
@GetMapping("/page")
public Result<PageResult<DiaryPostResponse>> getPage(@Validated BasePageRequest request)
// 根据用户ID分页查询日记
@GetMapping("/user/{userId}/page")
public Result<PageResult<DiaryPostResponse>> getPageByUserId(@PathVariable String userId,
@Validated BasePageRequest request)
// 根据用户ID查询公开日记
@GetMapping("/user/{userId}/public/page")
public Result<PageResult<DiaryPostResponse>> getPublicPageByUserId(@PathVariable String userId,
@Validated BasePageRequest request)
// 查询精选日记
@GetMapping("/featured/page")
public Result<PageResult<DiaryPostResponse>> getFeaturedPage(@Validated BasePageRequest request)
// 根据ID查询日记
@GetMapping("/{id}")
public Result<DiaryPostResponse> getById(@PathVariable String id)
// 创建日记
@PostMapping
public Result<DiaryPostResponse> create(@Valid @RequestBody DiaryPostCreateRequest request)
// 发布日记
@PostMapping("/publish")
public Result<DiaryPostResponse> publish(@Valid @RequestBody DiaryPostCreateRequest request)
// 更新日记
@PutMapping("/{id}")
public Result<DiaryPostResponse> update(@PathVariable String id, @Valid @RequestBody DiaryPostUpdateRequest request)
// 删除日记
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id)
// 软删除日记
@DeleteMapping("/{id}/soft")
public Result<Void> softDelete(@PathVariable String id)
// 恢复日记
@PutMapping("/{id}/restore")
public Result<Void> restore(@PathVariable String id)
// 点赞日记
@PostMapping("/{id}/like")
public Result<Void> like(@PathVariable String id)
// 取消点赞
@DeleteMapping("/{id}/like")
public Result<Void> unlike(@PathVariable String id)
// 分享日记
@PostMapping("/{id}/share")
public Result<Void> share(@PathVariable String id)
// 设置精选状态
@PutMapping("/{id}/featured/{featured}")
public Result<Void> setFeatured(@PathVariable String id, @PathVariable Integer featured)
// 设置优先级
@PutMapping("/{id}/priority/{priority}")
public Result<Void> setPriority(@PathVariable String id, @PathVariable Integer priority)
// 统计用户日记数量
@GetMapping("/user/{userId}/count")
public Result<Long> countByUserId(@PathVariable String userId)
// 统计用户公开日记数量
@GetMapping("/user/{userId}/public/count")
public Result<Long> countPublicByUserId(@PathVariable String userId)
// 统计精选日记数量
@GetMapping("/featured/count")
public Result<Long> countFeatured()
}
```
#### 3.3.3 技术特点
- **分页查询**: 支持灵活的分页查询
- **软删除**: 支持数据软删除和恢复
- **权限控制**: 支持公开/私密日记管理
- **社交功能**: 支持点赞、分享等社交功能
- **AI点评**: 集成AI自动点评功能
- **数据统计**: 提供丰富的统计功能
### 3.4 WebSocket实时通信系统
#### 3.4.1 功能概述
基于Spring WebSocket的实时通信系统,支持AI对话、消息推送、在线状态管理。
#### 3.4.2 核心组件
```java
// WebSocket配置
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue", "/user");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS();
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthInterceptor);
}
}
```
#### 3.4.3 技术特点
- **STOMP协议**: 使用STOMP消息协议
- **Token认证**: 支持WebSocket连接时的Token认证
- **消息路由**: 支持点对点和广播消息
- **会话管理**: 支持用户会话隔离
- **心跳检测**: 支持连接心跳检测
### 3.5 数据分析系统
#### 3.5.1 情绪分析 (EmotionAnalysisController)
```java
@RestController
@RequestMapping("/emotion-analysis")
public class EmotionAnalysisController {
// 分析用户情绪趋势
@GetMapping("/user/{userId}/trend")
public Result<EmotionTrendResponse> analyzeUserEmotionTrend(@PathVariable String userId,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate)
// 分析日记情绪
@PostMapping("/diary")
public Result<EmotionAnalysisResponse> analyzeDiaryEmotion(@Valid @RequestBody DiaryEmotionAnalysisRequest request)
// 获取情绪统计
@GetMapping("/user/{userId}/stats")
public Result<EmotionStatsResponse> getEmotionStats(@PathVariable String userId)
}
```
#### 3.5.2 用户统计 (UserStatsController)
```java
@RestController
@RequestMapping("/user-stats")
public class UserStatsController {
// 获取用户成长数据
@GetMapping("/user/{userId}/growth")
public Result<UserGrowthResponse> getUserGrowth(@PathVariable String userId)
// 获取用户活跃度
@GetMapping("/user/{userId}/activity")
public Result<UserActivityResponse> getUserActivity(@PathVariable String userId)
// 获取用户成就统计
@GetMapping("/user/{userId}/achievements")
public Result<UserAchievementsResponse> getUserAchievements(@PathVariable String userId)
}
```
## 4. 数据模型设计
### 4.1 核心实体关系
#### 4.1.1 用户相关实体
```java
// 用户实体
@Entity
public class User {
private String id; // 用户ID
private String username; // 用户名
private String email; // 邮箱
private String phone; // 手机号
private String avatar; // 头像
private String nickname; // 昵称
private Integer status; // 状态
private LocalDateTime createTime; // 创建时间
private LocalDateTime updateTime; // 更新时间
}
// 访客用户实体
@Entity
public class GuestUser {
private String id; // 访客ID
private String sessionId; // 会话ID
private String ipAddress; // IP地址
private String userAgent; // 用户代理
private LocalDateTime createTime; // 创建时间
private LocalDateTime lastActiveTime; // 最后活跃时间
}
```
#### 4.1.2 内容相关实体
```java
// 日记实体
@Entity
public class DiaryPost {
private String id; // 日记ID
private String userId; // 用户ID
private String title; // 标题
private String content; // 内容
private String aiComment; // AI点评
private Integer visibility; // 可见性
private Integer featured; // 精选状态
private Integer priority; // 优先级
private Integer likeCount; // 点赞数
private Integer shareCount; // 分享数
private LocalDateTime createTime; // 创建时间
private LocalDateTime updateTime; // 更新时间
}
// 会话实体
@Entity
public class Conversation {
private String id; // 会话ID
private String userId; // 用户ID
private String title; // 会话标题
private String summary; // 会话总结
private Integer messageCount; // 消息数量
private LocalDateTime createTime; // 创建时间
private LocalDateTime updateTime; // 更新时间
}
// 消息实体
@Entity
public class Message {
private String id; // 消息ID
private String conversationId; // 会话ID
private String senderId; // 发送者ID
private String senderType; // 发送者类型
private String content; // 消息内容
private String messageType; // 消息类型
private Integer status; // 消息状态
private LocalDateTime createTime; // 创建时间
}
```
#### 4.1.3 社交相关实体
```java
// 社区帖子实体
@Entity
public class CommunityPost {
private String id; // 帖子ID
private String userId; // 用户ID
private String title; // 标题
private String content; // 内容
private String category; // 分类
private Integer likeCount; // 点赞数
private Integer commentCount; // 评论数
private Integer shareCount; // 分享数
private LocalDateTime createTime; // 创建时间
}
// 评论实体
@Entity
public class Comment {
private String id; // 评论ID
private String userId; // 用户ID
private String targetId; // 目标ID
private String targetType; // 目标类型
private String content; // 评论内容
private String parentId; // 父评论ID
private Integer likeCount; // 点赞数
private LocalDateTime createTime; // 创建时间
}
```
### 4.2 数据访问层设计
#### 4.2.1 MyBatis Plus配置
```java
@Configuration
@MapperScan("com.emotion.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
```
#### 4.2.2 基础实体设计
```java
@MappedSuperclass
@Data
public abstract class BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private String id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;
}
```
## 5. 安全认证设计
### 5.1 JWT认证机制
#### 5.1.1 JWT配置
```yaml
# application.yml
emotion:
jwt:
secret: EmotionMuseumJWTSecretKey2025ForAuthenticationAndAuthorizationSecureEnoughForHS512Algorithm
expiration: 86400000 # 24小时
header: Authorization
prefix: "Bearer "
```
#### 5.1.2 JWT工具类
```java
@Component
public class JwtUtil {
@Value("${emotion.jwt.secret}")
private String secret;
@Value("${emotion.jwt.expiration}")
private Long expiration;
// 生成Token
public String generateToken(String userId, String username) {
return Jwts.builder()
.setSubject(userId)
.claim("username", username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
// 验证Token
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
// 从Token中获取用户ID
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}
```
### 5.2 Spring Security配置
#### 5.2.1 安全配置
```java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors().and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/auth/**", "/health/**", "/actuator/**").permitAll()
.antMatchers("/ai/guest/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
```
#### 5.2.2 JWT认证过滤器
```java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractTokenFromRequest(request);
if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
String userId = jwtUtil.getUserIdFromToken(token);
String username = jwtUtil.getUsernameFromToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
```
## 6. 缓存设计
### 6.1 Redis配置
```java
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置序列化器
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
```
### 6.2 缓存策略
- **用户信息缓存**: 用户基本信息缓存,TTL 30分钟
- **会话缓存**: 用户会话信息缓存,TTL 24小时
- **验证码缓存**: 验证码缓存,TTL 5分钟
- **热点数据缓存**: 热门日记、评论等缓存,TTL 1小时
## 7. 异步处理设计
### 7.1 异步配置
```java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("emotion-async-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}
```
### 7.2 异步应用场景
- **AI调用**: AI对话和总结生成
- **消息推送**: 实时消息推送
- **数据统计**: 用户行为统计分析
- **文件处理**: 图片上传和处理
## 8. 统一返回结果设计
### 8.1 返回结果封装
```java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private Integer code; // 状态码
private String message; // 消息
private T data; // 数据
private Long timestamp; // 时间戳
public static <T> Result<T> success() {
return new Result<>(200, "操作成功", null, System.currentTimeMillis());
}
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data, System.currentTimeMillis());
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data, System.currentTimeMillis());
}
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null, System.currentTimeMillis());
}
}
```
### 8.2 分页结果封装
```java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult<T> {
private Long current; // 当前页
private Long size; // 页大小
private Long total; // 总记录数
private Long pages; // 总页数
private List<T> records; // 数据列表
}
```
## 9. 异常处理设计
### 9.1 全局异常处理器
```java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessExceptio
File diff suppressed because it is too large Load Diff
+724
View File
@@ -0,0 +1,724 @@
# 情绪博物馆后端重构计划
## 1. 重构概述
### 1.1 重构目标
基于现有的Spring Boot 2.7.18单体架构,通过系统性的重构升级到Spring Boot 3.4.8版本,全面提升系统的性能、安全性、可维护性和扩展性,同时保持业务功能的完整性和稳定性。
### 1.2 重构原则
- **业务连续性**: 确保重构过程中业务功能不受影响
- **渐进式重构**: 分阶段、分模块进行重构,降低风险
- **向后兼容**: 保持现有API接口的兼容性
- **性能优先**: 充分利用新版本的技术优势
- **安全第一**: 采用最新的安全标准和最佳实践
- **质量保证**: 每个阶段都要进行充分的测试验证
### 1.3 重构范围
- **核心框架**: Spring Boot 2.7.18 → 3.4.8
- **Java版本**: JDK 17 → JDK 21 (LTS)
- **安全框架**: Spring Security 5.x → 6.x
- **数据访问**: MyBatis-Plus 3.5.3.1 → 3.5.5
- **JWT库**: 0.11.5 → 0.12.3
- **API文档**: Swagger → SpringDoc OpenAPI 3
- **AI集成**: 优化Coze API调用方式
## 2. 现状分析
### 2.1 当前系统架构
- **技术栈**: Spring Boot 2.7.18 + JDK 17
- **架构模式**: 单体架构
- **核心模块**: 24个控制器,涵盖认证、AI对话、日记、社区等功能
- **数据存储**: MySQL 8.0 + Redis
- **安全认证**: JWT + Spring Security 5.x
- **实时通信**: WebSocket + STOMP
### 2.2 存在的问题
1. **技术栈老旧**: Spring Boot 2.7.18已接近生命周期末期
2. **安全风险**: 旧版本存在已知安全漏洞
3. **性能瓶颈**: 缺乏现代化的性能优化特性
4. **维护困难**: 代码结构需要优化,缺乏统一规范
5. **扩展性差**: 单体架构限制了系统的扩展能力
### 2.3 重构收益
1. **性能提升**: 预期响应时间提升20%以上
2. **安全增强**: 采用最新的安全特性和标准
3. **开发效率**: 使用最新的Java特性和工具
4. **维护性**: 更好的代码结构和文档
5. **扩展性**: 为未来微服务化奠定基础
## 3. 重构策略
### 3.1 整体策略
采用**渐进式重构**策略,将整个重构过程分为4个主要阶段,每个阶段都有明确的目标和验收标准,确保重构过程的可控性和风险最小化。
### 3.2 技术选型
- **核心框架**: Spring Boot 3.4.8 (最新稳定版)
- **Java版本**: JDK 21 (LTS版本)
- **安全框架**: Spring Security 6.x
- **数据访问**: MyBatis-Plus 3.5.5
- **HTTP客户端**: WebClient + RestTemplate
- **API文档**: SpringDoc OpenAPI 3
- **缓存**: Redis 8.x
- **数据库**: MySQL 8.2
### 3.3 架构优化
- **分层架构**: 优化Controller-Service-Mapper分层
- **配置管理**: 统一配置管理,支持多环境
- **异常处理**: 全局异常处理机制
- **日志系统**: 结构化日志,支持ELK
- **监控告警**: 集成Prometheus + Grafana
## 4. 重构阶段规划
### 第一阶段:基础环境升级 (2-3周)
#### 4.1.1 目标
完成基础框架和开发环境的升级,建立新的技术栈基础。
#### 4.1.2 具体任务
- [ ] **环境准备**
- [ ] 升级JDK到21版本
- [ ] 更新Maven配置
- [ ] 配置新的开发环境
- [ ] 建立新的代码仓库分支
- [ ] **依赖升级**
- [ ] 升级Spring Boot到3.4.8
- [ ] 升级Spring Security到6.x
- [ ] 升级MyBatis-Plus到3.5.5
- [ ] 升级JWT到0.12.3
- [ ] 添加SpringDoc OpenAPI 3依赖
- [ ] **配置迁移**
- [ ] 迁移application.yml配置
- [ ] 更新数据库连接配置
- [ ] 配置Redis连接
- [ ] 设置多环境配置
#### 4.1.3 验收标准
- [ ] 项目能够正常启动
- [ ] 基础依赖无冲突
- [ ] 配置加载正常
- [ ] 数据库连接正常
#### 4.1.4 风险控制
- **风险**: 依赖版本冲突
- **应对**: 逐步升级,及时解决冲突
- **回滚**: 保留原版本代码分支
### 第二阶段:核心功能重构 (4-5周)
#### 4.2.1 目标
重构核心业务功能,确保主要功能模块在新框架下正常工作。
#### 4.2.2 具体任务
- [ ] **认证系统重构**
- [ ] 重构AuthController
- [ ] 升级JWT认证机制
- [ ] 优化Spring Security配置
- [ ] 实现新的认证过滤器
- [ ] **AI对话系统重构**
- [ ] 重构AiChatController
- [ ] 实现Coze API客户端
- [ ] 优化异步处理机制
- [ ] 完善错误处理
- [ ] **用户管理系统重构**
- [ ] 重构UserController
- [ ] 优化用户信息管理
- [ ] 实现用户权限控制
- [ ] 完善用户统计功能
- [ ] **日记系统重构**
- [ ] 重构DiaryPostController
- [ ] 优化日记CRUD操作
- [ ] 实现AI点评功能
- [ ] 完善社交分享功能
#### 4.2.3 验收标准
- [ ] 用户注册登录功能正常
- [ ] AI对话功能正常
- [ ] 日记发布管理功能正常
- [ ] 用户信息管理功能正常
#### 4.2.4 风险控制
- **风险**: 业务逻辑变更导致功能异常
- **应对**: 保持业务逻辑不变,只升级技术实现
- **测试**: 每个模块完成后进行功能测试
### 第三阶段:高级功能重构 (3-4周)
#### 4.3.1 目标
重构高级功能模块,包括WebSocket、社区互动、数据分析等。
#### 4.3.2 具体任务
- [ ] **WebSocket系统重构**
- [ ] 重构WebSocket配置
- [ ] 优化实时通信机制
- [ ] 实现消息推送功能
- [ ] 完善连接管理
- [ ] **社区系统重构**
- [ ] 重构CommunityPostController
- [ ] 重构CommentController
- [ ] 优化点赞分享功能
- [ ] 实现内容审核机制
- [ ] **数据分析系统重构**
- [ ] 重构EmotionAnalysisController
- [ ] 优化数据统计功能
- [ ] 实现实时数据分析
- [ ] 完善报表生成功能
- [ ] **消息系统重构**
- [ ] 重构MessageController
- [ ] 重构ConversationController
- [ ] 优化消息存储机制
- [ ] 实现消息推送功能
#### 4.3.3 验收标准
- [ ] WebSocket连接稳定
- [ ] 社区互动功能正常
- [ ] 数据分析功能正常
- [ ] 消息系统功能正常
#### 4.3.4 风险控制
- **风险**: 实时通信功能异常
- **应对**: 充分测试WebSocket连接
- **监控**: 实时监控连接状态
### 第四阶段:性能优化和测试 (2-3周)
#### 4.4.1 目标
进行性能优化,完善测试覆盖,确保系统稳定性和性能。
#### 4.4.2 具体任务
- [ ] **性能优化**
- [ ] 优化数据库查询
- [ ] 实现多级缓存
- [ ] 优化异步处理
- [ ] 配置连接池参数
- [ ] **测试完善**
- [ ] 编写单元测试
- [ ] 编写集成测试
- [ ] 进行性能测试
- [ ] 进行安全测试
- [ ] **监控告警**
- [ ] 集成Prometheus监控
- [ ] 配置Grafana仪表板
- [ ] 设置告警规则
- [ ] 完善日志系统
- [ ] **文档完善**
- [ ] 更新API文档
- [ ] 编写部署文档
- [ ] 完善运维文档
- [ ] 更新开发文档
#### 4.4.3 验收标准
- [ ] 性能指标达到预期
- [ ] 测试覆盖率>80%
- [ ] 监控告警正常
- [ ] 文档完整准确
#### 4.4.4 风险控制
- **风险**: 性能优化引入新问题
- **应对**: 逐步优化,充分测试
- **监控**: 实时监控系统性能
## 5. 详细实施计划
### 5.1 第一阶段详细计划
#### 5.1.1 第1周:环境准备
**Day 1-2: 环境搭建**
- 安装JDK 21
- 配置Maven环境
- 创建新的代码分支
- 配置IDE开发环境
**Day 3-4: 依赖升级**
- 升级Spring Boot到3.4.8
- 升级Spring Security到6.x
- 解决依赖冲突
- 验证基础功能
**Day 5: 配置迁移**
- 迁移application.yml
- 配置数据库连接
- 配置Redis连接
- 测试基础连接
#### 5.1.2 第2周:基础配置
**Day 1-2: 安全配置**
- 配置Spring Security 6.x
- 实现JWT认证
- 配置CORS策略
- 测试认证功能
**Day 3-4: 数据访问配置**
- 配置MyBatis-Plus 3.5.5
- 优化数据库连接池
- 配置Redis缓存
- 测试数据访问
**Day 5: API文档配置**
- 集成SpringDoc OpenAPI 3
- 配置API文档
- 编写基础API文档
- 测试文档访问
#### 5.1.3 第3周:基础功能验证
**Day 1-2: 启动类重构**
- 重构EmotionSimpleApplication
- 配置组件扫描
- 配置自动配置
- 测试应用启动
**Day 3-4: 基础控制器测试**
- 测试健康检查接口
- 测试基础API接口
- 验证配置加载
- 修复发现的问题
**Day 5: 第一阶段验收**
- 进行第一阶段验收测试
- 编写验收报告
- 准备第二阶段工作
- 团队评审和总结
### 5.2 第二阶段详细计划
#### 5.2.1 第4周:认证系统重构
**Day 1-2: AuthController重构**
- 重构登录接口
- 重构注册接口
- 重构Token刷新接口
- 测试认证功能
**Day 3-4: 安全机制升级**
- 升级JWT实现
- 优化认证过滤器
- 实现权限控制
- 测试安全功能
**Day 5: 用户管理基础**
- 重构UserController基础功能
- 实现用户信息查询
- 实现用户信息更新
- 测试用户管理功能
#### 5.2.2 第5周:AI对话系统重构
**Day 1-2: Coze API客户端**
- 实现CozeClient
- 实现CozeRestTemplateClient
- 配置Coze API参数
- 测试API调用
**Day 3-4: AiChatController重构**
- 重构聊天接口
- 重构总结接口
- 实现异步处理
- 测试AI功能
**Day 5: 消息管理**
- 重构MessageController
- 实现消息存储
- 实现消息查询
- 测试消息功能
#### 5.2.3 第6周:日记系统重构
**Day 1-2: DiaryPostController重构**
- 重构日记CRUD接口
- 实现分页查询
- 实现搜索功能
- 测试日记功能
**Day 3-4: 社交功能**
- 实现点赞功能
- 实现分享功能
- 实现评论功能
- 测试社交功能
**Day 5: AI点评功能**
- 实现AI点评接口
- 集成Coze API
- 优化点评逻辑
- 测试点评功能
#### 5.2.4 第7周:会话管理重构
**Day 1-2: ConversationController重构**
- 重构会话创建
- 重构会话查询
- 实现会话管理
- 测试会话功能
**Day 3-4: 数据统计**
- 实现用户统计
- 实现对话统计
- 实现日记统计
- 测试统计功能
**Day 5: 第二阶段验收**
- 进行第二阶段验收测试
- 编写验收报告
- 准备第三阶段工作
- 团队评审和总结
### 5.3 第三阶段详细计划
#### 5.3.1 第8周:WebSocket系统重构
**Day 1-2: WebSocket配置**
- 重构WebSocketConfig
- 配置STOMP协议
- 实现连接管理
- 测试WebSocket连接
**Day 3-4: 实时通信**
- 实现消息推送
- 实现在线状态
- 实现群聊功能
- 测试实时通信
**Day 5: 消息处理**
- 实现消息处理器
- 实现消息路由
- 优化消息格式
- 测试消息处理
#### 5.3.2 第9周:社区系统重构
**Day 1-2: CommunityPostController重构**
- 重构社区帖子管理
- 实现帖子发布
- 实现帖子查询
- 测试社区功能
**Day 3-4: 评论系统**
- 重构CommentController
- 实现评论功能
- 实现回复功能
- 测试评论功能
**Day 5: 互动功能**
- 实现点赞功能
- 实现收藏功能
- 实现分享功能
- 测试互动功能
#### 5.3.3 第10周:数据分析系统重构
**Day 1-2: EmotionAnalysisController重构**
- 重构情绪分析
- 实现数据分析
- 实现趋势分析
- 测试分析功能
**Day 3-4: 统计报表**
- 实现用户统计
- 实现内容统计
- 实现行为统计
- 测试统计功能
**Day 5: 第三阶段验收**
- 进行第三阶段验收测试
- 编写验收报告
- 准备第四阶段工作
- 团队评审和总结
### 5.4 第四阶段详细计划
#### 5.4.1 第11周:性能优化
**Day 1-2: 数据库优化**
- 优化SQL查询
- 添加数据库索引
- 优化连接池配置
- 测试数据库性能
**Day 3-4: 缓存优化**
- 实现多级缓存
- 优化缓存策略
- 配置缓存参数
- 测试缓存效果
**Day 5: 异步优化**
- 优化异步处理
- 配置线程池
- 实现任务队列
- 测试异步性能
#### 5.4.2 第12周:测试完善
**Day 1-2: 单元测试**
- 编写Controller测试
- 编写Service测试
- 编写Mapper测试
- 提高测试覆盖率
**Day 3-4: 集成测试**
- 编写API集成测试
- 编写数据库集成测试
- 编写缓存集成测试
- 测试系统集成
**Day 5: 性能测试**
- 进行压力测试
- 进行并发测试
- 进行稳定性测试
- 分析测试结果
#### 5.4.3 第13周:监控和文档
**Day 1-2: 监控系统**
- 集成Prometheus
- 配置Grafana仪表板
- 设置告警规则
- 测试监控功能
**Day 3-4: 日志系统**
- 配置结构化日志
- 实现日志聚合
- 配置日志分析
- 测试日志功能
**Day 5: 文档完善**
- 更新API文档
- 编写部署文档
- 完善运维文档
- 最终验收测试
## 6. 风险管理
### 6.1 技术风险
#### 6.1.1 依赖升级风险
- **风险描述**: Spring Boot 3.x与现有依赖可能存在兼容性问题
- **影响程度**: 高
- **应对措施**:
- 逐步升级依赖,及时解决冲突
- 保留原版本代码分支,确保可回滚
- 建立完善的测试机制
#### 6.1.2 数据库兼容性风险
- **风险描述**: 新版本框架可能影响数据库操作
- **影响程度**: 中
- **应对措施**:
- 充分测试数据库操作
- 准备数据库迁移脚本
- 建立数据备份机制
#### 6.1.3 性能风险
- **风险描述**: 新框架可能影响系统性能
- **影响程度**: 中
- **应对措施**:
- 进行充分的性能测试
- 建立性能基准
- 实时监控系统性能
### 6.2 业务风险
#### 6.2.1 功能异常风险
- **风险描述**: 重构过程中可能影响业务功能
- **影响程度**: 高
- **应对措施**:
- 保持业务逻辑不变
- 分阶段重构,及时验证
- 建立完善的测试机制
#### 6.2.2 数据安全风险
- **风险描述**: 重构过程中可能影响数据安全
- **影响程度**: 高
- **应对措施**:
- 建立数据备份机制
- 加强安全测试
- 实施访问控制
### 6.3 项目风险
#### 6.3.1 进度风险
- **风险描述**: 重构进度可能延期
- **影响程度**: 中
- **应对措施**:
- 制定详细的时间计划
- 建立里程碑检查点
- 准备应急预案
#### 6.3.2 人员风险
- **风险描述**: 团队成员可能缺乏新技术的经验
- **影响程度**: 中
- **应对措施**:
- 进行技术培训
- 建立知识分享机制
- 引入外部技术支持
## 7. 质量保证
### 7.1 测试策略
#### 7.1.1 单元测试
- **覆盖率要求**: >80%
- **测试范围**: Controller、Service、Mapper层
- **测试工具**: JUnit 5 + Mockito
- **执行频率**: 每次代码提交
#### 7.1.2 集成测试
- **测试范围**: API接口、数据库操作、缓存操作
- **测试工具**: Spring Boot Test
- **执行频率**: 每个阶段完成后
#### 7.1.3 性能测试
- **测试范围**: 响应时间、并发处理、资源使用
- **测试工具**: JMeter + Prometheus
- **执行频率**: 每个阶段完成后
#### 7.1.4 安全测试
- **测试范围**: 认证授权、数据安全、接口安全
- **测试工具**: OWASP ZAP
- **执行频率**: 每个阶段完成后
### 7.2 代码质量
#### 7.2.1 代码规范
- **编码规范**: 遵循阿里巴巴Java开发手册
- **代码审查**: 每个PR必须经过代码审查
- **静态分析**: 使用SonarQube进行代码质量分析
#### 7.2.2 文档要求
- **API文档**: 使用SpringDoc自动生成
- **代码注释**: 关键业务逻辑必须有注释
- **架构文档**: 更新系统架构文档
### 7.3 部署质量
#### 7.3.1 部署流程
- **环境隔离**: 开发、测试、生产环境分离
- **自动化部署**: 使用CI/CD流水线
- **回滚机制**: 支持快速回滚
#### 7.3.2 监控告警
- **系统监控**: 使用Prometheus + Grafana
- **日志监控**: 使用ELK Stack
- **告警机制**: 设置合理的告警阈值
## 8. 验收标准
### 8.1 功能验收标准
#### 8.1.1 基础功能
- [ ] 用户注册登录功能正常
- [ ] JWT认证机制正常工作
- [ ] 用户信息管理功能正常
- [ ] 基础API接口响应正常
#### 8.1.2 核心功能
- [ ] AI对话功能正常
- [ ] 日记发布管理功能正常
- [ ] 社区互动功能正常
- [ ] 消息系统功能正常
#### 8.1.3 高级功能
- [ ] WebSocket实时通信正常
- [ ] 数据分析功能正常
- [ ] 文件上传功能正常
- [ ] 搜索功能正常
### 8.2 性能验收标准
#### 8.2.1 响应时间
- [ ] API接口平均响应时间 < 200ms
- [ ] 数据库查询平均响应时间 < 50ms
- [ ] 缓存命中率 > 90%
#### 8.2.2 并发处理
- [ ] 支持1000并发用户
- [ ] 系统稳定性测试通过
- [ ] 内存使用率 < 80%
#### 8.2.3 可用性
- [ ] 系统可用性 > 99.9%
- [ ] 故障恢复时间 < 5分钟
- [ ] 数据备份恢复正常
### 8.3 安全验收标准
#### 8.3.1 认证授权
- [ ] JWT认证机制安全
- [ ] 权限控制正确
- [ ] 会话管理安全
#### 8.3.2 数据安全
- [ ] 敏感数据加密存储
- [ ] 数据传输安全
- [ ] SQL注入防护
#### 8.3.3 接口安全
- [ ] API接口安全测试通过
- [ ] CORS配置正确
- [ ] 请求频率限制
### 8.4 技术验收标准
#### 8.4.1 代码质量
- [ ] 代码覆盖率 > 80%
- [ ] SonarQube质量门禁通过
- [ ] 代码审查通过
#### 8.4.2 文档完整性
- [ ] API文档完整准确
- [ ] 部署文档完整
- [ ] 运维文档完整
#### 8.4.3 监控告警
- [ ] 监控系统正常工作
- [ ] 告警机制正常
- [ ] 日志系统正常
## 9. 团队组织
### 9.1 团队结构
- **项目经理**: 负责整体项目管理和协调
- **技术负责人**: 负责技术方案设计和架构决策
- **后端开发工程师**: 负责具体功能开发
- **测试工程师**: 负责测试用例设计和执行
- **运维工程师**: 负责部署和运维支持
### 9.2 职责分工
- **项目经理**: 进度管理、风险控制、资源协调
- **技术负责人**: 技术方案、架构设计、代码审查
- **后端开发工程师**: 功能开发、单元测试、文档编写
- **测试工程师**: 测试计划、测试执行、质量保证
- **运维工程师**: 环境搭建、部署支持、监控配置
### 9.3 沟通机制
- **日常沟通**: 每日站会,同步进度和问题
- **周例会**: 每周总结会议,评审进度和计划
- **里程碑会议**: 每个阶段结束后的评审会议
- **技术分享**: 定期技术分享,提升团队能力
## 10. 总结
### 10.1 重构价值
通过本次重构,情绪博物馆后端系统将获得以下价值:
1. **技术现代化**: 采用最新的Spring Boot 3.4.8和JDK 21
2. **性能提升**: 预期性能提升20%以上
3. **安全增强**: 采用最新的安全特性和标准
4. **可维护性**: 更好的代码结构和文档
5. **扩展性**: 为未来功能扩展奠定基础
### 10.2 成功关键因素
1. **充分的准备**: 详细的技术方案和计划
2. **渐进式重构**: 分阶段进行,降低风险
3. **质量保证**: 完善的测试和监控机制
4. **团队协作**: 良好的沟通和协作机制
5. **持续改进**: 根据实际情况调整计划
### 10.3 后续规划
重构完成后,将进行以下后续工作:
1. **性能优化**: 持续的性能监控和优化
2. **功能扩展**: 基于新架构的功能扩展
3. **微服务化**: 为未来的微服务化做准备
4. **技术升级**: 持续关注新技术,及时升级
5. **团队建设**: 提升团队技术能力
这个重构计划将为情绪博物馆后端系统带来显著的技术提升和业务价值,为项目的长期发展奠定坚实的基础。
+247
View File
@@ -0,0 +1,247 @@
# 情绪博物馆后端重构完成总结
## 项目概述
本项目成功完成了情绪博物馆后端服务从Spring Boot 2.7.18到Spring Boot 3.4.8的全面升级重构,采用了最新的技术栈和最佳实践。
## 重构成果
### 第一阶段:基础环境升级 ✅
#### 技术栈升级
- **Spring Boot**: 2.7.18 → 3.4.8
- **Java版本**: JDK 17 (计划升级到JDK 21)
- **Spring Security**: 5.x → 6.x
- **MyBatis-Plus**: 3.5.3.1 → 3.5.5
- **JWT**: 0.11.5 → 0.12.3
- **API文档**: Swagger → SpringDoc OpenAPI 3
#### 基础配置完成
- ✅ Maven项目配置 (pom.xml)
- ✅ 主配置文件 (application.yml, application-local.yml)
- ✅ 主启动类 (EmotionMuseumApplication.java)
- ✅ 基础配置类 (SecurityConfig, MybatisPlusConfig, RedisConfig等)
### 第二阶段:核心功能重构 ✅
#### 1. 认证系统重构 ✅
- **AuthService**: 用户认证服务接口
- **AuthServiceImpl**: 用户认证服务实现
- **AuthController**: 认证控制器
- **JwtUtil**: JWT工具类 (适配JWT 0.12.3)
- **功能**: 用户注册、登录、登出、令牌刷新、令牌验证
#### 2. 用户管理系统重构 ✅
- **UserService**: 用户服务接口
- **UserServiceImpl**: 用户服务实现
- **UserController**: 用户控制器
- **功能**: 用户信息管理、密码修改、用户列表、用户状态管理
#### 3. AI对话系统重构 ✅
- **CozeApiService**: Coze API服务接口
- **CozeApiServiceImpl**: Coze API服务实现
- **AiChatService**: AI聊天服务接口
- **AiChatServiceImpl**: AI聊天服务实现
- **AiChatController**: AI聊天控制器
- **功能**: 与Coze Bot对话、会话管理、消息历史、AI状态检查
#### 4. 日记系统重构 ✅
- **DiaryPostRequest**: 日记请求DTO
- **DiaryPostService**: 日记服务接口
- **DiaryPostServiceImpl**: 日记服务实现
- **DiaryPostController**: 日记控制器
- **功能**: 日记CRUD、AI点评、点赞、情绪标签、公开/私密设置
#### 5. WebSocket系统重构 ✅
- **WebSocketConfig**: WebSocket配置
- **ChatMessage**: WebSocket消息DTO
- **WebSocketController**: WebSocket控制器
- **功能**: 实时聊天、AI对话、消息推送、用户状态同步
## 技术亮点
### 1. 现代化技术栈
- 采用Spring Boot 3.4.8最新版本
- 使用Spring Security 6.x最新安全框架
- 集成SpringDoc OpenAPI 3现代化API文档
- 支持WebSocket实时通信
### 2. 完善的认证体系
- JWT 0.12.3最新版本适配
- Redis令牌存储和验证
- 完整的用户认证流程
- 安全的密码加密存储
### 3. AI集成能力
- 直接集成Coze API
- 支持上下文对话
- 异步AI回复处理
- 智能错误处理机制
### 4. 实时通信支持
- WebSocket + STOMP协议
- 支持SockJS和原生WebSocket
- 实时消息推送
- 用户状态同步
### 5. 数据访问优化
- MyBatis-Plus 3.5.5最新版本
- 分页查询支持
- 逻辑删除
- 乐观锁机制
## 项目结构
```
server/
├── src/main/java/com/emotionmuseum/
│ ├── config/ # 配置类
│ │ ├── SecurityConfig.java
│ │ ├── MybatisPlusConfig.java
│ │ ├── RedisConfig.java
│ │ ├── WebSocketConfig.java
│ │ └── ...
│ ├── controller/ # 控制器层
│ │ ├── AuthController.java
│ │ ├── UserController.java
│ │ ├── AiChatController.java
│ │ ├── DiaryPostController.java
│ │ └── WebSocketController.java
│ ├── service/ # 服务层
│ │ ├── AuthService.java
│ │ ├── UserService.java
│ │ ├── AiChatService.java
│ │ ├── DiaryPostService.java
│ │ ├── CozeApiService.java
│ │ └── impl/ # 服务实现
│ ├── mapper/ # 数据访问层
│ │ ├── UserMapper.java
│ │ ├── DiaryPostMapper.java
│ │ ├── MessageMapper.java
│ │ └── ConversationMapper.java
│ ├── entity/ # 实体类
│ │ ├── User.java
│ │ ├── DiaryPost.java
│ │ ├── Message.java
│ │ └── Conversation.java
│ ├── dto/ # 数据传输对象
│ │ ├── Result.java
│ │ ├── auth/ # 认证相关DTO
│ │ ├── diary/ # 日记相关DTO
│ │ └── websocket/ # WebSocket相关DTO
│ ├── util/ # 工具类
│ │ └── JwtUtil.java
│ └── EmotionMuseumApplication.java
├── src/main/resources/
│ ├── application.yml
│ ├── application-local.yml
│ └── mapper/ # MyBatis映射文件
├── pom.xml # Maven配置
└── README.md # 项目文档
```
## API接口概览
### 认证接口
- `POST /api/auth/login` - 用户登录
- `POST /api/auth/register` - 用户注册
- `POST /api/auth/logout` - 用户登出
- `POST /api/auth/refresh` - 刷新令牌
- `GET /api/auth/validate` - 验证令牌
### 用户接口
- `GET /api/user/profile` - 获取用户信息
- `PUT /api/user/profile` - 更新用户信息
- `POST /api/user/change-password` - 修改密码
- `GET /api/user/list` - 获取用户列表
### AI聊天接口
- `POST /api/ai/chat/send` - 发送消息
- `POST /api/ai/conversation/create` - 创建会话
- `GET /api/ai/conversation/list` - 获取会话列表
- `GET /api/ai/conversation/{id}/messages` - 获取会话消息
- `DELETE /api/ai/conversation/{id}` - 删除会话
- `POST /api/ai/conversation/{id}/clear` - 清空会话
- `GET /api/ai/status` - 检查AI状态
### 日记接口
- `POST /api/diary/create` - 创建日记
- `PUT /api/diary/{id}` - 更新日记
- `GET /api/diary/{id}` - 获取日记详情
- `GET /api/diary/user/list` - 获取用户日记列表
- `GET /api/diary/public/list` - 获取公开日记列表
- `GET /api/diary/emotion/{tag}` - 根据情绪标签查询
- `DELETE /api/diary/{id}` - 删除日记
- `POST /api/diary/{id}/like` - 点赞日记
- `POST /api/diary/{id}/unlike` - 取消点赞
- `GET /api/diary/{id}/ai-comment` - 获取AI点评
### WebSocket接口
- `/ws` - WebSocket连接端点
- `/app/chat.sendMessage` - 发送聊天消息
- `/app/chat.addUser` - 用户加入聊天
- `/app/ai.chat` - AI聊天消息
- `/app/chat.typing` - 用户输入状态
- `/topic/public` - 公共消息主题
- `/queue/ai.response` - AI回复队列
## 部署说明
### 环境要求
- JDK 17+
- Maven 3.6+
- MySQL 8.0+
- Redis 7.0+
### 启动步骤
1. 配置环境变量或修改application-local.yml
2. 启动MySQL和Redis服务
3. 执行编译:`mvn clean compile`
4. 启动应用:`mvn spring-boot:run`
### 访问地址
- 应用地址:http://localhost:19089
- API文档:http://localhost:19089/api/swagger-ui.html
- 健康检查:http://localhost:19089/api/health
## 下一步计划
### 第三阶段:高级功能重构
1. **社区系统重构**
- 评论功能
- 用户关注
- 内容推荐
2. **统计分析系统**
- 用户行为分析
- 情绪趋势分析
- 数据可视化
3. **通知系统**
- 消息推送
- 邮件通知
- 系统公告
### 第四阶段:性能优化和测试
1. **性能优化**
- 缓存优化
- 数据库优化
- 并发处理优化
2. **测试完善**
- 单元测试
- 集成测试
- 性能测试
## 总结
本次重构成功完成了情绪博物馆后端服务的全面升级,实现了:
1. **技术栈现代化**: 升级到Spring Boot 3.4.8等最新技术
2. **功能完整性**: 覆盖认证、用户管理、AI对话、日记、WebSocket等核心功能
3. **架构优化**: 采用分层架构,代码结构清晰,易于维护
4. **安全性提升**: 使用Spring Security 6.x和JWT 0.12.3
5. **实时通信**: 支持WebSocket实时消息推送
6. **AI集成**: 直接集成Coze API,支持智能对话
项目已经具备了完整的后端服务能力,为前端应用提供了稳定、安全、高效的API支持。
+254
View File
@@ -0,0 +1,254 @@
# 情绪博物馆后端重构进度总结
## 重构概述
本次重构成功完成了情绪博物馆后端服务从Spring Boot 2.7.18到Spring Boot 3.4.8的全面升级,采用了最新的技术栈和最佳实践。
## 已完成的重构工作
### 第一阶段:基础环境升级 ✅
#### 技术栈升级
- **Spring Boot**: 2.7.18 → 3.4.8
- **Java版本**: JDK 17 (计划升级到JDK 21)
- **Spring Security**: 5.x → 6.x
- **MyBatis-Plus**: 3.5.3.1 → 3.5.5
- **JWT**: 0.11.5 → 0.12.3
- **API文档**: Swagger → SpringDoc OpenAPI 3
#### 基础配置完成
- ✅ Maven项目配置 (pom.xml)
- ✅ 主配置文件 (application.yml, application-local.yml)
- ✅ 主启动类 (EmotionMuseumApplication.java)
- ✅ 基础配置类 (SecurityConfig, MybatisPlusConfig, RedisConfig等)
### 第二阶段:核心功能重构 ✅
#### 1. 认证系统重构 ✅
- **AuthService**: 用户认证服务接口
- **AuthServiceImpl**: 用户认证服务实现
- **AuthController**: 认证控制器
- **JwtUtil**: JWT工具类 (适配JWT 0.12.3)
- **功能**: 用户注册、登录、登出、令牌刷新、令牌验证
#### 2. 用户管理系统重构 ✅
- **UserService**: 用户服务接口
- **UserServiceImpl**: 用户服务实现
- **UserController**: 用户控制器
- **功能**: 用户信息管理、密码修改、用户列表、用户状态管理
#### 3. AI对话系统重构 ✅
- **CozeApiService**: Coze API服务接口
- **CozeApiServiceImpl**: Coze API服务实现
- **AiChatService**: AI聊天服务接口
- **AiChatServiceImpl**: AI聊天服务实现
- **AiChatController**: AI聊天控制器
- **功能**: 与Coze Bot对话、会话管理、消息历史、AI状态检查
#### 4. 日记系统重构 ✅
- **DiaryPostRequest**: 日记请求DTO
- **DiaryPostService**: 日记服务接口
- **DiaryPostServiceImpl**: 日记服务实现
- **DiaryPostController**: 日记控制器
- **功能**: 日记CRUD、AI点评、点赞、情绪标签、公开/私密设置
#### 5. WebSocket系统重构 ✅
- **WebSocketConfig**: WebSocket配置
- **ChatMessage**: WebSocket消息DTO
- **WebSocketController**: WebSocket控制器
- **功能**: 实时聊天、AI对话、消息推送、用户状态同步
#### 6. 社区系统重构 🔄
- **Comment**: 评论实体
- **UserFollow**: 用户关注实体
- **CommentRequest/CommentResponse**: 评论DTO
- **CommentMapper/UserFollowMapper**: 数据访问层
- **CommentService/UserFollowService**: 服务接口
- **CommentServiceImpl**: 评论服务实现
- **功能**: 评论、回复、点赞、用户关注
## 技术亮点
### 1. 现代化技术栈
- 采用Spring Boot 3.4.8最新版本
- 使用Spring Security 6.x最新安全框架
- 集成SpringDoc OpenAPI 3现代化API文档
- 支持WebSocket实时通信
### 2. 完善的认证体系
- JWT 0.12.3最新版本适配
- Redis令牌存储和验证
- 完整的用户认证流程
- 安全的密码加密存储
### 3. AI集成能力
- 直接集成Coze API
- 支持上下文对话
- 异步AI回复处理
- 智能错误处理机制
### 4. 实时通信支持
- WebSocket + STOMP协议
- 支持SockJS和原生WebSocket
- 实时消息推送
- 用户状态同步
### 5. 数据访问优化
- MyBatis-Plus 3.5.5最新版本
- 分页查询支持
- 逻辑删除
- 乐观锁机制
## 项目结构
```
server/
├── src/main/java/com/emotionmuseum/
│ ├── config/ # 配置类
│ │ ├── SecurityConfig.java
│ │ ├── MybatisPlusConfig.java
│ │ ├── RedisConfig.java
│ │ ├── WebSocketConfig.java
│ │ └── ...
│ ├── controller/ # 控制器层
│ │ ├── AuthController.java
│ │ ├── UserController.java
│ │ ├── AiChatController.java
│ │ ├── DiaryPostController.java
│ │ └── WebSocketController.java
│ ├── service/ # 服务层
│ │ ├── AuthService.java
│ │ ├── UserService.java
│ │ ├── AiChatService.java
│ │ ├── DiaryPostService.java
│ │ ├── CozeApiService.java
│ │ ├── CommentService.java
│ │ ├── UserFollowService.java
│ │ └── impl/ # 服务实现
│ ├── mapper/ # 数据访问层
│ │ ├── UserMapper.java
│ │ ├── DiaryPostMapper.java
│ │ ├── MessageMapper.java
│ │ ├── ConversationMapper.java
│ │ ├── CommentMapper.java
│ │ └── UserFollowMapper.java
│ ├── entity/ # 实体类
│ │ ├── User.java
│ │ ├── DiaryPost.java
│ │ ├── Message.java
│ │ ├── Conversation.java
│ │ ├── Comment.java
│ │ └── UserFollow.java
│ ├── dto/ # 数据传输对象
│ │ ├── Result.java
│ │ ├── auth/ # 认证相关DTO
│ │ ├── diary/ # 日记相关DTO
│ │ ├── comment/ # 评论相关DTO
│ │ └── websocket/ # WebSocket相关DTO
│ ├── util/ # 工具类
│ │ └── JwtUtil.java
│ └── EmotionMuseumApplication.java
├── src/main/resources/
│ ├── application.yml
│ ├── application-local.yml
│ └── mapper/ # MyBatis映射文件
├── pom.xml # Maven配置
└── README.md # 项目文档
```
## API接口概览
### 认证接口
- `POST /api/auth/login` - 用户登录
- `POST /api/auth/register` - 用户注册
- `POST /api/auth/logout` - 用户登出
- `POST /api/auth/refresh` - 刷新令牌
- `GET /api/auth/validate` - 验证令牌
### 用户接口
- `GET /api/user/profile` - 获取用户信息
- `PUT /api/user/profile` - 更新用户信息
- `POST /api/user/change-password` - 修改密码
- `GET /api/user/list` - 获取用户列表
### AI聊天接口
- `POST /api/ai/chat/send` - 发送消息
- `POST /api/ai/conversation/create` - 创建会话
- `GET /api/ai/conversation/list` - 获取会话列表
- `GET /api/ai/conversation/{id}/messages` - 获取会话消息
- `DELETE /api/ai/conversation/{id}` - 删除会话
- `POST /api/ai/conversation/{id}/clear` - 清空会话
- `GET /api/ai/status` - 检查AI状态
### 日记接口
- `POST /api/diary/create` - 创建日记
- `PUT /api/diary/{id}` - 更新日记
- `GET /api/diary/{id}` - 获取日记详情
- `GET /api/diary/user/list` - 获取用户日记列表
- `GET /api/diary/public/list` - 获取公开日记列表
- `GET /api/diary/emotion/{tag}` - 根据情绪标签查询
- `DELETE /api/diary/{id}` - 删除日记
- `POST /api/diary/{id}/like` - 点赞日记
- `POST /api/diary/{id}/unlike` - 取消点赞
- `GET /api/diary/{id}/ai-comment` - 获取AI点评
### 评论接口
- `POST /api/comment/create` - 创建评论
- `GET /api/comment/content/{contentType}/{contentId}` - 获取内容评论
- `GET /api/comment/{id}` - 获取评论详情
- `DELETE /api/comment/{id}` - 删除评论
- `POST /api/comment/{id}/like` - 点赞评论
- `POST /api/comment/{id}/unlike` - 取消点赞评论
- `GET /api/comment/user/{userId}` - 获取用户评论
- `GET /api/comment/{id}/replies` - 获取评论回复
### WebSocket接口
- `/ws` - WebSocket连接端点
- `/app/chat.sendMessage` - 发送聊天消息
- `/app/chat.addUser` - 用户加入聊天
- `/app/ai.chat` - AI聊天消息
- `/app/chat.typing` - 用户输入状态
- `/topic/public` - 公共消息主题
- `/queue/ai.response` - AI回复队列
## 下一步计划
### 第三阶段:高级功能重构
1. **社区系统完善**
- 用户关注功能实现
- 评论控制器
- 社区内容推荐
2. **统计分析系统**
- 用户行为分析
- 情绪趋势分析
- 数据可视化
3. **通知系统**
- 消息推送
- 邮件通知
- 系统公告
### 第四阶段:性能优化和测试
1. **性能优化**
- 缓存优化
- 数据库优化
- 并发处理优化
2. **测试完善**
- 单元测试
- 集成测试
- 性能测试
## 总结
本次重构已经完成了情绪博物馆后端服务的核心功能升级,包括:
1. **技术栈现代化**: 升级到Spring Boot 3.4.8等最新技术
2. **功能完整性**: 覆盖认证、用户管理、AI对话、日记、WebSocket、社区等核心功能
3. **架构优化**: 采用分层架构,代码结构清晰,易于维护
4. **安全性提升**: 使用Spring Security 6.x和JWT 0.12.3
5. **实时通信**: 支持WebSocket实时消息推送
6. **AI集成**: 直接集成Coze API,支持智能对话
项目已经具备了完整的后端服务能力,为前端应用提供了稳定、安全、高效的API支持。后续将继续完善高级功能和性能优化。
+7
View File
@@ -0,0 +1,7 @@
# 开发环境配置
VITE_APP_ENV=dev
VITE_API_BASE_URL=https://dev-api.emotion-museum.com/api
VITE_WS_BASE_URL=wss://dev-api.emotion-museum.com
VITE_UPLOAD_URL=https://dev-api.emotion-museum.com/api/upload
VITE_DEBUG=true
VITE_MOCK=false
+7
View File
@@ -0,0 +1,7 @@
# 生产环境配置
VITE_APP_ENV=prod
VITE_API_BASE_URL=https://api.emotion-museum.com/api
VITE_WS_BASE_URL=wss://api.emotion-museum.com
VITE_UPLOAD_URL=https://api.emotion-museum.com/api/upload
VITE_DEBUG=false
VITE_MOCK=false
+314
View File
@@ -0,0 +1,314 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"acceptHMRUpdate": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createPinia": true,
"createReactiveFn": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getActivePinia": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"mapActions": true,
"mapGetters": true,
"mapState": true,
"mapStores": true,
"mapWritableState": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"pausableWatch": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"setActivePinia": true,
"setMapStoreSuffix": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"storeToRefs": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useAnimate": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useI18n": true,
"useId": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useModel": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true,
"ElMessage": true
}
}
+85
View File
@@ -0,0 +1,85 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting',
'./.eslintrc-auto-import.json'
],
parserOptions: {
ecmaVersion: 'latest'
},
env: {
node: true,
browser: true,
es2022: true
},
globals: {
defineEmits: 'readonly',
defineProps: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly'
},
rules: {
// Vue规则
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'vue/require-explicit-emits': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always'
},
svg: 'always',
math: 'always'
}
],
// TypeScript规则
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/prefer-ts-expect-error': 'error',
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'off', // 使用TypeScript版本
'prefer-const': 'error',
'no-var': 'error',
// 代码风格
'eqeqeq': ['error', 'always'],
'curly': ['error', 'all'],
'brace-style': ['error', '1tbs'],
'comma-dangle': ['error', 'never'],
'quotes': ['error', 'single', { avoidEscape: true }],
'semi': ['error', 'never']
},
overrides: [
{
files: ['cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}'],
extends: ['plugin:cypress/recommended']
},
{
files: ['src/**/__tests__/**/*', 'src/**/*.{test,spec}.*'],
env: {
vitest: true
}
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"printWidth": 100,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore",
"embeddedLanguageFormatting": "auto"
}
+213
View File
@@ -0,0 +1,213 @@
# 情绪博物馆 Web 端
基于 Vue 3 + TypeScript + Vite 的现代化前端应用,为用户提供情绪记录和心理健康服务。
## ✨ 特性
- 🚀 **现代化技术栈**: Vue 3.4.21 + TypeScript 5.4.2 + Vite 5.1.6
- 🎨 **优雅的UI**: Element Plus 2.6.1 + Tailwind CSS 3.4.1
- 📱 **响应式设计**: 支持桌面端和移动端
- 🔐 **完整的认证**: JWT Token + 自动刷新
- 🌐 **实时通信**: 原生WebSocket + STOMP协议
- 📊 **数据可视化**: ECharts 5.5.0 图表展示
- 🌍 **国际化**: Vue I18n 多语言支持
- 📦 **PWA支持**: 离线访问和桌面安装
- 🧪 **完整测试**: Vitest + Cypress 测试覆盖
## 🏗️ 项目结构
```
web-new/
├── public/ # 静态资源
├── src/
│ ├── api/ # API接口定义
│ ├── assets/ # 资源文件
│ ├── components/ # 通用组件
│ ├── composables/ # 组合式API
│ ├── config/ # 配置文件
│ ├── i18n/ # 国际化
│ ├── layouts/ # 布局组件
│ ├── plugins/ # 插件
│ ├── router/ # 路由配置
│ ├── stores/ # 状态管理
│ ├── types/ # 类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── tests/ # 测试文件
├── .env # 环境变量
├── package.json # 依赖配置
├── vite.config.ts # Vite配置
└── README.md # 项目文档
```
## 🚀 快速开始
### 环境要求
- Node.js >= 18.0.0
- npm >= 9.0.0
### 安装依赖
```bash
npm install
```
### 开发环境
```bash
npm run dev
```
### 构建生产版本
```bash
npm run build
```
### 预览生产版本
```bash
npm run preview
```
## 🧪 测试
### 运行单元测试
```bash
npm run test
```
### 运行E2E测试
```bash
npm run test:e2e
```
### 测试覆盖率
```bash
npm run test:coverage
```
## 📦 核心依赖
### 框架和工具
- **Vue 3.4.21**: 渐进式JavaScript框架
- **TypeScript 5.4.2**: 类型安全的JavaScript
- **Vite 5.1.6**: 下一代前端构建工具
- **Vue Router 4.3.0**: 官方路由管理器
- **Pinia 2.1.7**: 官方状态管理库
### UI和样式
- **Element Plus 2.6.1**: Vue 3 UI组件库
- **Tailwind CSS 3.4.1**: 实用优先的CSS框架
- **@element-plus/icons-vue**: Element Plus图标
### 网络和通信
- **Axios 1.6.8**: HTTP客户端
- **@stomp/stompjs 7.1.1**: WebSocket STOMP协议
### 数据可视化
- **ECharts 5.5.0**: 强大的图表库
- **vue-echarts 6.7.3**: Vue ECharts组件
### 工具库
- **@vueuse/core 10.9.0**: Vue组合式工具集
- **Day.js 1.11.10**: 轻量级日期库
- **Lodash-es 4.17.21**: 实用工具库
## 🔧 开发工具
### 代码质量
- **ESLint 8.57.0**: 代码检查
- **Prettier 3.2.5**: 代码格式化
- **Husky 9.0.11**: Git钩子
- **lint-staged 15.2.2**: 暂存文件检查
### 自动化
- **unplugin-auto-import**: 自动导入API
- **unplugin-vue-components**: 自动导入组件
- **vite-plugin-pwa**: PWA支持
### 测试
- **Vitest 1.4.0**: 单元测试框架
- **@vue/test-utils 2.4.5**: Vue组件测试工具
- **Cypress 13.7.1**: E2E测试框架
## 🌍 环境配置
项目支持多环境配置:
- **local**: 本地开发环境
- **dev**: 开发服务器环境
- **test**: 测试环境
- **prod**: 生产环境
通过 `.env` 文件配置不同环境的参数。
## 📱 功能特性
### 核心功能
- **AI智能对话**: 与AI助手进行情绪交流
- **情绪日记**: 记录和分析日常情绪
- **数据可视化**: 情绪趋势和统计分析
- **个人仪表盘**: 全面的个人数据展示
### 技术特性
- **响应式设计**: 适配各种设备尺寸
- **暗色主题**: 支持明暗主题切换
- **国际化**: 中英文语言切换
- **PWA**: 支持离线访问和桌面安装
- **实时通信**: WebSocket实时消息推送
## 🔐 安全特性
- **JWT认证**: 安全的用户认证机制
- **Token刷新**: 自动Token刷新和过期处理
- **权限控制**: 基于角色的访问控制
- **路由守卫**: 页面访问权限验证
- **XSS防护**: 内容安全策略
## 📈 性能优化
- **代码分割**: 按需加载减少初始包大小
- **Tree Shaking**: 移除未使用的代码
- **图片优化**: 支持WebP格式和懒加载
- **缓存策略**: 合理的缓存配置
- **Gzip压缩**: 减少传输大小
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 📞 联系我们
- 项目地址: [GitHub](https://github.com/emotion-museum/web)
- 问题反馈: [Issues](https://github.com/emotion-museum/web/issues)
- 邮箱: contact@emotion-museum.com
---
**情绪博物馆** - 记录情绪,分享心情的温暖空间 ❤️
+310
View File
@@ -0,0 +1,310 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+19
View File
@@ -0,0 +1,19 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
EmojiPicker: typeof import('./src/components/emoji/EmojiPicker.vue')['default']
ErrorBoundary: typeof import('./src/components/error/ErrorBoundary.vue')['default']
FileUpload: typeof import('./src/components/upload/FileUpload.vue')['default']
ImageUpload: typeof import('./src/components/upload/ImageUpload.vue')['default']
NotificationCenter: typeof import('./src/components/notification/NotificationCenter.vue')['default']
RichTextEditor: typeof import('./src/components/editor/RichTextEditor.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Cypress E2E 测试配置
*/
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
// 基础URL
baseUrl: 'http://localhost:5173',
// 测试文件位置
specPattern: 'tests/e2e/**/*.cy.{js,jsx,ts,tsx}',
// 支持文件位置
supportFile: 'tests/e2e/support/e2e.ts',
// 固件文件位置
fixturesFolder: 'tests/e2e/fixtures',
// 截图和视频配置
screenshotsFolder: 'tests/e2e/screenshots',
videosFolder: 'tests/e2e/videos',
// 视频录制
video: true,
videoCompression: 32,
// 截图配置
screenshotOnRunFailure: true,
// 视口配置
viewportWidth: 1280,
viewportHeight: 720,
// 等待配置
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
// 重试配置
retries: {
runMode: 2,
openMode: 0
},
// 浏览器配置
chromeWebSecurity: false,
// 环境变量
env: {
// API基础URL
apiUrl: 'http://localhost:3000/api',
// 测试用户凭据
testUser: {
username: 'testuser',
password: 'password123',
email: 'test@example.com'
},
// 测试管理员凭据
adminUser: {
username: 'admin',
password: 'admin123',
email: 'admin@example.com'
}
},
setupNodeEvents(on, config) {
// 任务注册
on('task', {
// 数据库清理任务
clearDatabase() {
// 这里可以添加数据库清理逻辑
return null
},
// 创建测试数据任务
seedTestData() {
// 这里可以添加测试数据创建逻辑
return null
},
// 日志输出任务
log(message) {
console.log(message)
return null
}
})
// 文件处理
on('before:browser:launch', (browser, launchOptions) => {
if (browser.name === 'chrome') {
// Chrome 特定配置
launchOptions.args.push('--disable-dev-shm-usage')
launchOptions.args.push('--no-sandbox')
}
return launchOptions
})
// 配置处理
on('before:spec', (spec) => {
console.log(`Running spec: ${spec.name}`)
})
return config
}
},
component: {
// 组件测试配置
devServer: {
framework: 'vue',
bundler: 'vite'
},
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx,vue}',
supportFile: 'tests/e2e/support/component.ts',
viewportWidth: 1000,
viewportHeight: 660
}
})
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="情绪博物馆 - 记录情绪,分享心情的温暖空间" />
<meta name="keywords" content="情绪,日记,AI对话,心理健康,情感记录" />
<meta name="author" content="情绪博物馆团队" />
<!-- PWA相关 -->
<meta name="theme-color" content="#4A90E2" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<!-- 预加载关键资源 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- 字体 -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<!-- 内容安全策略 -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https: blob:;
connect-src 'self' ws: wss: https:;
media-src 'self' blob:;">
<title>情绪博物馆</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+15735
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
{
"name": "emotion-museum-web",
"version": "1.0.0",
"description": "情绪博物馆Web端 - Vue3+TypeScript重构版本",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build:check": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open",
"test:e2e:ci": "start-server-and-test dev http://localhost:5173 'cypress run'",
"test:all": "npm run test:unit && npm run test:e2e",
"build:analyze": "vite build --mode analyze",
"build:staging": "vite build --mode staging",
"build:production": "vite build --mode production",
"prepare": "husky install"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"axios": "^1.6.8",
"@stomp/stompjs": "^7.1.1",
"element-plus": "^2.6.1",
"@element-plus/icons-vue": "^2.3.1",
"tailwindcss": "^3.4.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"echarts": "^5.5.0",
"vue-echarts": "^6.7.3",
"@vueuse/core": "^10.9.0",
"dayjs": "^1.11.10",
"lodash-es": "^4.17.21",
"zod": "^3.22.4",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"@vueuse/motion": "^2.0.0",
"vue-toastification": "^2.0.0-rc.5",
"nprogress": "^0.2.0",
"vue-upload-component": "^3.1.4",
"cropperjs": "^1.6.1",
"file-saver": "^2.0.5",
"@tiptap/vue-3": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@tiptap/extension-image": "^2.2.4",
"vue-i18n": "^9.10.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"vue-tsc": "^2.0.6",
"vite": "^5.1.6",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite-plugin-pwa": "^0.19.2",
"workbox-window": "^7.0.0",
"eslint": "^8.57.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/eslint-config-prettier": "^9.0.0",
"prettier": "^3.2.5",
"lint-staged": "^15.2.2",
"husky": "^9.0.11",
"vitest": "^1.4.0",
"@vue/test-utils": "^2.4.5",
"jsdom": "^24.0.0",
"cypress": "^13.7.1",
"rollup-plugin-visualizer": "^5.12.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-mock": "^3.0.1",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"@types/lodash-es": "^4.17.12",
"@types/nprogress": "^0.2.3",
"@types/file-saver": "^2.0.7",
"@types/cropperjs": "^1.3.5",
"tailwindcss": "^3.4.1",
"@types/node": "^20.11.25",
"start-server-and-test": "^2.0.3"
},
"lint-staged": {
"*.{vue,js,ts}": [
"eslint --fix",
"prettier --write"
]
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+118
View File
@@ -0,0 +1,118 @@
<template>
<div id="app" class="min-h-screen bg-gray-50">
<!-- 全局加载进度条 -->
<div v-if="isLoading" class="fixed top-0 left-0 w-full h-1 bg-primary-500 z-50 animate-pulse" />
<!-- 路由视图 -->
<router-view v-slot="{ Component, route }">
<transition
:name="route.meta.transition || 'fade'"
mode="out-in"
appear
>
<component :is="Component" :key="route.path" />
</transition>
</router-view>
<!-- 全局通知容器 -->
<Teleport to="body">
<div id="toast-container" />
</Teleport>
</div>
</template>
<script setup lang="ts">
import { provide, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useOnline, useDocumentVisibility } from '@vueuse/core'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
// 应用状态
const appStore = useAppStore()
const authStore = useAuthStore()
// 响应式数据
const isLoading = computed(() => appStore.isLoading)
// 提供全局状态
provide('appStore', appStore)
provide('authStore', authStore)
// 应用初始化
onMounted(async () => {
try {
// 初始化应用配置
await appStore.initialize()
// 检查用户登录状态
await authStore.checkAuthStatus()
console.log('✅ 应用初始化完成')
} catch (error) {
console.error('❌ 应用初始化失败:', error)
}
})
// 监听网络状态
const { isOnline } = useOnline()
watch(isOnline, (online) => {
if (online) {
ElMessage.success('网络连接已恢复')
} else {
ElMessage.warning('网络连接已断开')
}
})
// 监听页面可见性
const { visibility } = useDocumentVisibility()
watch(visibility, (current) => {
if (current === 'visible') {
// 页面重新可见时,检查认证状态
authStore.refreshTokenIfNeeded()
}
})
</script>
<style scoped>
/* 页面过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
transform: translateY(20px);
opacity: 0;
}
.slide-up-leave-to {
transform: translateY(-20px);
opacity: 0;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
/**
* 认证相关API接口
*/
import request from '@/utils/request'
import { API_PATHS } from '@/config/constants'
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
RefreshTokenRequest,
RefreshTokenResponse,
CaptchaResponse,
OAuthLoginRequest,
UserInfo
} from '@/types/api'
export const authApi = {
/**
* 用户登录
*/
login(data: LoginRequest): Promise<LoginResponse> {
return request.post(API_PATHS.AUTH.LOGIN, data, {
skipAuth: true,
showLoading: true,
loadingText: '正在登录...'
})
},
/**
* 用户注册
*/
register(data: RegisterRequest): Promise<RegisterResponse> {
return request.post(API_PATHS.AUTH.REGISTER, data, {
skipAuth: true,
showLoading: true,
loadingText: '正在注册...'
})
},
/**
* 用户登出
*/
logout(): Promise<void> {
return request.post(API_PATHS.AUTH.LOGOUT)
},
/**
* 刷新Token
*/
refreshToken(data: RefreshTokenRequest): Promise<RefreshTokenResponse> {
return request.post(API_PATHS.AUTH.REFRESH_TOKEN, data, {
skipAuth: true,
skipErrorHandler: true
})
},
/**
* 获取验证码
*/
getCaptcha(): Promise<CaptchaResponse> {
return request.get(API_PATHS.AUTH.CAPTCHA, undefined, {
skipAuth: true
})
},
/**
* 第三方登录
*/
oauthLogin(data: OAuthLoginRequest): Promise<LoginResponse> {
return request.post(API_PATHS.AUTH.OAUTH_LOGIN, data, {
skipAuth: true,
showLoading: true,
loadingText: '正在登录...'
})
},
/**
* 获取用户信息
*/
getUserInfo(): Promise<UserInfo> {
return request.get(API_PATHS.AUTH.USER_INFO)
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 对话相关API接口
*/
import request from '@/utils/request'
import { API_PATHS } from '@/config/constants'
import type {
CreateConversationRequest,
ConversationInfo,
GetUserConversationsRequest,
MessageInfo,
GetUserMessagesRequest,
SearchUserMessagesRequest,
GetRecentMessagesRequest
} from '@/types/api'
export const conversationApi = {
/**
* 创建新对话
*/
create(data: CreateConversationRequest): Promise<ConversationInfo> {
return request.post(API_PATHS.CONVERSATION.CREATE, data, {
showLoading: true,
loadingText: '创建中...'
})
},
/**
* 获取用户对话列表
*/
getUserConversations(params: GetUserConversationsRequest): Promise<PageResponse<ConversationInfo>> {
return request.get(API_PATHS.CONVERSATION.USER_LIST, params)
},
/**
* 删除对话
*/
delete(conversationId: string): Promise<void> {
return request.delete(`${API_PATHS.CONVERSATION.DELETE}/${conversationId}`, {
showLoading: true,
loadingText: '删除中...'
})
}
}
export const messageApi = {
/**
* 获取用户消息列表
*/
getUserMessages(params: GetUserMessagesRequest): Promise<PageResponse<MessageInfo>> {
return request.get(API_PATHS.MESSAGE.USER_PAGE, params)
},
/**
* 搜索用户消息
*/
searchUserMessages(params: SearchUserMessagesRequest): Promise<PageResponse<MessageInfo>> {
return request.get(API_PATHS.MESSAGE.USER_SEARCH, params)
},
/**
* 获取最近消息
*/
getRecentMessages(params: GetRecentMessagesRequest): Promise<MessageInfo[]> {
return request.get(API_PATHS.MESSAGE.USER_RECENT, params)
},
/**
* 获取消息详情
*/
getMessageDetail(messageId: string): Promise<MessageInfo> {
return request.get(`${API_PATHS.MESSAGE.DETAIL}/${messageId}`)
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 日记相关API接口
*/
import request from '@/utils/request'
import { API_PATHS } from '@/config/constants'
import type {
DiaryPost,
PublishDiaryRequest,
GetUserDiariesRequest
} from '@/types/api'
export const diaryApi = {
/**
* 发布日记
*/
publish(data: PublishDiaryRequest): Promise<DiaryPost> {
return request.post(API_PATHS.DIARY.PUBLISH, data, {
showLoading: true,
loadingText: '发布中...'
})
},
/**
* 获取用户日记列表
*/
getUserDiaries(params: GetUserDiariesRequest): Promise<PageResponse<DiaryPost>> {
return request.get(API_PATHS.DIARY.USER_PAGE, params)
},
/**
* 获取日记详情
*/
getDiaryDetail(diaryId: string): Promise<DiaryPost> {
return request.get(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`)
},
/**
* 更新日记
*/
updateDiary(diaryId: string, data: Partial<PublishDiaryRequest>): Promise<DiaryPost> {
return request.put(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`, data, {
showLoading: true,
loadingText: '保存中...'
})
},
/**
* 删除日记
*/
deleteDiary(diaryId: string): Promise<void> {
return request.delete(`${API_PATHS.DIARY.PUBLISH}/${diaryId}`, {
showLoading: true,
loadingText: '删除中...'
})
},
/**
* 保存草稿
*/
saveDraft(data: Partial<PublishDiaryRequest>): Promise<DiaryPost> {
return request.post(`${API_PATHS.DIARY.PUBLISH}/draft`, data)
},
/**
* 获取草稿列表
*/
getDrafts(): Promise<DiaryPost[]> {
return request.get(`${API_PATHS.DIARY.PUBLISH}/drafts`)
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 用户相关API接口
*/
import request from '@/utils/request'
import { API_PATHS } from '@/config/constants'
import type {
UserInfo,
UpdateUserProfileRequest,
ChangePasswordRequest,
UploadAvatarResponse,
VerifyEmailRequest,
SendEmailCodeRequest,
VerifyPhoneRequest,
SendPhoneCodeRequest,
UserGrowthStats
} from '@/types/api'
export const userApi = {
/**
* 获取用户资料
*/
getProfile(): Promise<UserInfo> {
return request.get(API_PATHS.USER.PROFILE)
},
/**
* 更新用户资料
*/
updateProfile(data: UpdateUserProfileRequest): Promise<UserInfo> {
return request.put(API_PATHS.USER.PROFILE, data, {
showLoading: true,
loadingText: '保存中...'
})
},
/**
* 修改密码
*/
changePassword(data: ChangePasswordRequest): Promise<void> {
return request.put(API_PATHS.USER.PASSWORD, data, {
showLoading: true,
loadingText: '修改中...'
})
},
/**
* 上传头像
*/
uploadAvatar(file: File): Promise<UploadAvatarResponse> {
return request.upload(API_PATHS.USER.AVATAR_UPLOAD, file, {
showLoading: true,
loadingText: '上传中...'
})
},
/**
* 获取用户成长数据
*/
getGrowthStats(): Promise<UserGrowthStats> {
return request.get(API_PATHS.USER.GROWTH_STATS)
},
/**
* 发送邮箱验证码
*/
sendEmailCode(data: SendEmailCodeRequest): Promise<void> {
return request.post(API_PATHS.USER.EMAIL_SEND_CODE, data)
},
/**
* 验证邮箱
*/
verifyEmail(data: VerifyEmailRequest): Promise<void> {
return request.post(API_PATHS.USER.EMAIL_VERIFY, data, {
showLoading: true,
loadingText: '验证中...'
})
},
/**
* 发送手机验证码
*/
sendPhoneCode(data: SendPhoneCodeRequest): Promise<void> {
return request.post(API_PATHS.USER.PHONE_SEND_CODE, data)
},
/**
* 验证手机号
*/
verifyPhone(data: VerifyPhoneRequest): Promise<void> {
return request.post(API_PATHS.USER.PHONE_VERIFY, data, {
showLoading: true,
loadingText: '验证中...'
})
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* 主样式文件
*/
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* 全局样式 */
* {
box-sizing: border-box;
}
html {
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
background-color: #f9fafb;
color: #374151;
line-height: 1.6;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 暗色主题 */
.dark {
background-color: #111827;
color: #f9fafb;
}
.dark ::-webkit-scrollbar-track {
background: #374151;
}
.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Element Plus 样式覆盖 */
.el-button--primary {
background-color: #4a90e2;
border-color: #4a90e2;
}
.el-button--primary:hover {
background-color: #357abd;
border-color: #357abd;
}
/* 自定义工具类 */
.text-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass-effect {
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass-effect {
background-color: rgba(17, 24, 39, 0.8);
border: 1px solid rgba(75, 85, 99, 0.2);
}
/* 动画类 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
transform: translateY(20px);
opacity: 0;
}
.slide-up-leave-to {
transform: translateY(-20px);
opacity: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
/* 响应式工具类 */
@media (max-width: 768px) {
.mobile-hidden {
display: none !important;
}
}
@media (min-width: 769px) {
.desktop-hidden {
display: none !important;
}
}
/* 打印样式 */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
}
/* 无障碍样式 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 焦点样式 */
.focus-visible:focus {
outline: 2px solid #4a90e2;
outline-offset: 2px;
}
/* 加载动画 */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 脉冲动画 */
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 弹跳动画 */
.bounce {
animation: bounce 1s infinite;
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 0, 0);
}
40%, 43% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -30px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -15px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
@@ -0,0 +1,472 @@
<template>
<div class="rich-text-editor">
<!-- 工具栏 -->
<div v-if="showToolbar" class="editor-toolbar">
<div class="toolbar-group">
<!-- 文本格式 -->
<el-button-group size="small">
<el-button @click="execCommand('bold')" :class="{ active: isActive('bold') }">
<el-icon><Bold /></el-icon>
</el-button>
<el-button @click="execCommand('italic')" :class="{ active: isActive('italic') }">
<el-icon><Italic /></el-icon>
</el-button>
<el-button @click="execCommand('underline')" :class="{ active: isActive('underline') }">
<el-icon><Underline /></el-icon>
</el-button>
<el-button @click="execCommand('strikeThrough')" :class="{ active: isActive('strikeThrough') }">
<el-icon><Strikethrough /></el-icon>
</el-button>
</el-button-group>
<!-- 对齐方式 -->
<el-button-group size="small">
<el-button @click="execCommand('justifyLeft')" :class="{ active: isActive('justifyLeft') }">
<el-icon><AlignLeft /></el-icon>
</el-button>
<el-button @click="execCommand('justifyCenter')" :class="{ active: isActive('justifyCenter') }">
<el-icon><AlignCenter /></el-icon>
</el-button>
<el-button @click="execCommand('justifyRight')" :class="{ active: isActive('justifyRight') }">
<el-icon><AlignRight /></el-icon>
</el-button>
</el-button-group>
<!-- 列表 -->
<el-button-group size="small">
<el-button @click="execCommand('insertUnorderedList')" :class="{ active: isActive('insertUnorderedList') }">
<el-icon><List /></el-icon>
</el-button>
<el-button @click="execCommand('insertOrderedList')" :class="{ active: isActive('insertOrderedList') }">
<el-icon><Numbered /></el-icon>
</el-button>
</el-button-group>
<!-- 其他功能 -->
<el-button-group size="small">
<el-button @click="insertLink">
<el-icon><Link /></el-icon>
</el-button>
<el-button @click="insertImage">
<el-icon><Picture /></el-icon>
</el-button>
<el-button @click="insertTable">
<el-icon><Grid /></el-icon>
</el-button>
</el-button-group>
<!-- 颜色 -->
<div class="color-picker">
<el-color-picker
v-model="textColor"
size="small"
@change="changeTextColor"
/>
</div>
<!-- 清除格式 -->
<el-button size="small" @click="clearFormat">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<!-- 编辑器内容区 -->
<div
ref="editorRef"
class="editor-content"
:style="{ height: height }"
contenteditable
@input="handleInput"
@paste="handlePaste"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
v-html="content"
/>
<!-- 字数统计 -->
<div v-if="showWordCount" class="editor-footer">
<span class="word-count">{{ wordCount }} </span>
<span v-if="maxLength" class="word-limit">/ {{ maxLength }}</span>
</div>
<!-- 插入链接对话框 -->
<el-dialog
v-model="linkDialogVisible"
title="插入链接"
width="400px"
>
<el-form :model="linkForm" label-width="80px">
<el-form-item label="链接文本">
<el-input v-model="linkForm.text" placeholder="请输入链接文本" />
</el-form-item>
<el-form-item label="链接地址">
<el-input v-model="linkForm.url" placeholder="请输入链接地址" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="linkDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmInsertLink">确定</el-button>
</template>
</el-dialog>
<!-- 插入图片对话框 -->
<el-dialog
v-model="imageDialogVisible"
title="插入图片"
width="500px"
>
<el-tabs v-model="imageTabActive">
<el-tab-pane label="上传图片" name="upload">
<ImageUpload
:limit="1"
:multiple="false"
@success="handleImageUpload"
/>
</el-tab-pane>
<el-tab-pane label="网络图片" name="url">
<el-form :model="imageForm" label-width="80px">
<el-form-item label="图片地址">
<el-input v-model="imageForm.url" placeholder="请输入图片地址" />
</el-form-item>
<el-form-item label="替代文本">
<el-input v-model="imageForm.alt" placeholder="请输入替代文本" />
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="imageDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmInsertImage">确定</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
List,
Link,
Picture,
Grid,
Delete
} from '@element-plus/icons-vue'
import ImageUpload from '@/components/upload/ImageUpload.vue'
interface Props {
modelValue?: string
placeholder?: string
height?: string
maxLength?: number
showToolbar?: boolean
showWordCount?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
height: '300px',
maxLength: 0,
showToolbar: true,
showWordCount: true,
disabled: false
})
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
(e: 'focus', event: FocusEvent): void
(e: 'blur', event: FocusEvent): void
}
const emit = defineEmits<Emits>()
// 响应式数据
const editorRef = ref<HTMLElement>()
const content = ref(props.modelValue)
const textColor = ref('#000000')
const linkDialogVisible = ref(false)
const imageDialogVisible = ref(false)
const imageTabActive = ref('upload')
const focused = ref(false)
// 表单数据
const linkForm = ref({
text: '',
url: ''
})
const imageForm = ref({
url: '',
alt: ''
})
// 计算属性
const wordCount = computed(() => {
const text = editorRef.value?.innerText || ''
return text.length
})
// 方法
const handleInput = () => {
if (!editorRef.value) return
const html = editorRef.value.innerHTML
content.value = html
// 检查字数限制
if (props.maxLength && wordCount.value > props.maxLength) {
ElMessage.warning(`内容不能超过 ${props.maxLength}`)
return
}
emit('update:modelValue', html)
emit('change', html)
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const clipboardData = event.clipboardData
if (!clipboardData) return
// 获取纯文本内容
const text = clipboardData.getData('text/plain')
// 插入文本
execCommand('insertText', text)
}
const handleKeydown = (event: KeyboardEvent) => {
// Ctrl+B 加粗
if (event.ctrlKey && event.key === 'b') {
event.preventDefault()
execCommand('bold')
}
// Ctrl+I 斜体
if (event.ctrlKey && event.key === 'i') {
event.preventDefault()
execCommand('italic')
}
// Ctrl+U 下划线
if (event.ctrlKey && event.key === 'u') {
event.preventDefault()
execCommand('underline')
}
}
const handleFocus = (event: FocusEvent) => {
focused.value = true
emit('focus', event)
}
const handleBlur = (event: FocusEvent) => {
focused.value = false
emit('blur', event)
}
const execCommand = (command: string, value?: string) => {
document.execCommand(command, false, value)
editorRef.value?.focus()
handleInput()
}
const isActive = (command: string): boolean => {
return document.queryCommandState(command)
}
const changeTextColor = (color: string) => {
execCommand('foreColor', color)
}
const insertLink = () => {
const selection = window.getSelection()
if (selection && selection.toString()) {
linkForm.value.text = selection.toString()
}
linkDialogVisible.value = true
}
const confirmInsertLink = () => {
if (!linkForm.value.url) {
ElMessage.warning('请输入链接地址')
return
}
const linkHtml = `<a href="${linkForm.value.url}" target="_blank">${linkForm.value.text || linkForm.value.url}</a>`
execCommand('insertHTML', linkHtml)
linkDialogVisible.value = false
linkForm.value = { text: '', url: '' }
}
const insertImage = () => {
imageDialogVisible.value = true
}
const handleImageUpload = (response: any) => {
const imageHtml = `<img src="${response.url}" alt="上传图片" style="max-width: 100%; height: auto;" />`
execCommand('insertHTML', imageHtml)
imageDialogVisible.value = false
}
const confirmInsertImage = () => {
if (!imageForm.value.url) {
ElMessage.warning('请输入图片地址')
return
}
const imageHtml = `<img src="${imageForm.value.url}" alt="${imageForm.value.alt}" style="max-width: 100%; height: auto;" />`
execCommand('insertHTML', imageHtml)
imageDialogVisible.value = false
imageForm.value = { url: '', alt: '' }
}
const insertTable = () => {
const tableHtml = `
<table border="1" style="border-collapse: collapse; width: 100%;">
<tr>
<td style="padding: 8px;">单元格1</td>
<td style="padding: 8px;">单元格2</td>
</tr>
<tr>
<td style="padding: 8px;">单元格3</td>
<td style="padding: 8px;">单元格4</td>
</tr>
</table>
`
execCommand('insertHTML', tableHtml)
}
const clearFormat = () => {
execCommand('removeFormat')
}
const focus = () => {
editorRef.value?.focus()
}
const blur = () => {
editorRef.value?.blur()
}
const getContent = () => {
return content.value
}
const setContent = (html: string) => {
content.value = html
if (editorRef.value) {
editorRef.value.innerHTML = html
}
}
// 监听外部值变化
watch(() => props.modelValue, (newValue) => {
if (newValue !== content.value) {
content.value = newValue
nextTick(() => {
if (editorRef.value) {
editorRef.value.innerHTML = newValue
}
})
}
})
// 生命周期
onMounted(() => {
if (editorRef.value && props.modelValue) {
editorRef.value.innerHTML = props.modelValue
}
})
// 暴露方法
defineExpose({
focus,
blur,
getContent,
setContent,
execCommand
})
</script>
<style scoped>
.rich-text-editor {
@apply border border-gray-300 rounded-lg overflow-hidden;
}
.editor-toolbar {
@apply bg-gray-50 border-b border-gray-300 p-2;
}
.toolbar-group {
@apply flex items-center space-x-2 flex-wrap;
}
.color-picker {
@apply flex items-center;
}
.editor-content {
@apply p-4 outline-none overflow-y-auto;
min-height: 200px;
}
.editor-content:empty:before {
content: attr(placeholder);
@apply text-gray-400;
}
.editor-footer {
@apply bg-gray-50 border-t border-gray-300 px-4 py-2 text-right text-sm text-gray-500;
}
.word-count {
@apply mr-1;
}
.word-limit {
@apply text-gray-400;
}
.dialog-footer {
@apply flex justify-end space-x-2 mt-4;
}
:deep(.el-button.active) {
@apply bg-blue-500 text-white;
}
:deep(.editor-content img) {
@apply max-w-full h-auto;
}
:deep(.editor-content table) {
@apply border-collapse w-full;
}
:deep(.editor-content table td) {
@apply border border-gray-300 p-2;
}
:deep(.editor-content a) {
@apply text-blue-500 underline;
}
</style>
@@ -0,0 +1,325 @@
<template>
<div class="emoji-picker">
<el-popover
:visible="visible"
:width="320"
trigger="manual"
placement="top-start"
popper-class="emoji-popover"
@hide="handleHide"
>
<template #reference>
<div @click="togglePicker">
<slot>
<el-button circle>
<el-icon><Sunny /></el-icon>
</el-button>
</slot>
</div>
</template>
<div class="emoji-picker-content">
<!-- 搜索框 -->
<div class="emoji-search">
<el-input
v-model="searchKeyword"
placeholder="搜索表情..."
size="small"
clearable
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 分类标签 -->
<div class="emoji-categories">
<div
v-for="category in categories"
:key="category.key"
class="category-tab"
:class="{ active: activeCategory === category.key }"
@click="switchCategory(category.key)"
>
<span class="category-icon">{{ category.icon }}</span>
<span class="category-name">{{ category.name }}</span>
</div>
</div>
<!-- 表情列表 -->
<div class="emoji-list" ref="emojiListRef">
<div v-if="filteredEmojis.length === 0" class="empty-state">
<p class="text-gray-500 text-sm">没有找到相关表情</p>
</div>
<div v-else class="emoji-grid">
<div
v-for="emoji in filteredEmojis"
:key="emoji.code"
class="emoji-item"
:title="emoji.name"
@click="selectEmoji(emoji)"
>
<span class="emoji-char">{{ emoji.char }}</span>
</div>
</div>
</div>
<!-- 最近使用 -->
<div v-if="recentEmojis.length > 0 && !searchKeyword" class="recent-emojis">
<div class="recent-title">最近使用</div>
<div class="emoji-grid">
<div
v-for="emoji in recentEmojis"
:key="emoji.code"
class="emoji-item"
:title="emoji.name"
@click="selectEmoji(emoji)"
>
<span class="emoji-char">{{ emoji.char }}</span>
</div>
</div>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Sunny, Search } from '@element-plus/icons-vue'
import { EMOJI_DATA } from '@/config/emoji'
import storage from '@/utils/storage'
interface EmojiItem {
char: string
code: string
name: string
category: string
keywords: string[]
}
interface Props {
visible?: boolean
maxRecent?: number
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
maxRecent: 20
})
interface Emits {
(e: 'select', emoji: EmojiItem): void
(e: 'update:visible', visible: boolean): void
}
const emit = defineEmits<Emits>()
// 响应式数据
const visible = ref(props.visible)
const searchKeyword = ref('')
const activeCategory = ref('smileys')
const emojiListRef = ref<HTMLElement>()
const recentEmojis = ref<EmojiItem[]>([])
// 表情分类
const categories = [
{ key: 'smileys', name: '笑脸', icon: '😀' },
{ key: 'people', name: '人物', icon: '👋' },
{ key: 'nature', name: '自然', icon: '🌱' },
{ key: 'food', name: '食物', icon: '🍎' },
{ key: 'activity', name: '活动', icon: '⚽' },
{ key: 'travel', name: '旅行', icon: '🚗' },
{ key: 'objects', name: '物品', icon: '💡' },
{ key: 'symbols', name: '符号', icon: '❤️' },
{ key: 'flags', name: '旗帜', icon: '🏳️' }
]
// 计算属性
const filteredEmojis = computed(() => {
let emojis = EMOJI_DATA.filter(emoji => {
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
return emoji.name.toLowerCase().includes(keyword) ||
emoji.keywords.some(k => k.toLowerCase().includes(keyword))
}
return emoji.category === activeCategory.value
})
return emojis.slice(0, 100) // 限制显示数量以提高性能
})
// 方法
const togglePicker = () => {
visible.value = !visible.value
emit('update:visible', visible.value)
}
const handleHide = () => {
visible.value = false
emit('update:visible', false)
}
const handleSearch = () => {
// 搜索时重置到第一个分类
if (searchKeyword.value) {
activeCategory.value = 'smileys'
}
}
const switchCategory = (category: string) => {
activeCategory.value = category
searchKeyword.value = ''
// 滚动到顶部
if (emojiListRef.value) {
emojiListRef.value.scrollTop = 0
}
}
const selectEmoji = (emoji: EmojiItem) => {
// 添加到最近使用
addToRecent(emoji)
// 发送选择事件
emit('select', emoji)
// 关闭选择器
visible.value = false
emit('update:visible', false)
}
const addToRecent = (emoji: EmojiItem) => {
// 移除已存在的相同表情
const index = recentEmojis.value.findIndex(item => item.code === emoji.code)
if (index > -1) {
recentEmojis.value.splice(index, 1)
}
// 添加到开头
recentEmojis.value.unshift(emoji)
// 限制数量
if (recentEmojis.value.length > props.maxRecent) {
recentEmojis.value = recentEmojis.value.slice(0, props.maxRecent)
}
// 保存到本地存储
saveRecentEmojis()
}
const loadRecentEmojis = () => {
const saved = storage.get('recent_emojis')
if (saved && Array.isArray(saved)) {
recentEmojis.value = saved
}
}
const saveRecentEmojis = () => {
storage.set('recent_emojis', recentEmojis.value)
}
// 点击外部关闭
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.emoji-picker') && !target.closest('.emoji-popover')) {
visible.value = false
emit('update:visible', false)
}
}
// 生命周期
onMounted(() => {
loadRecentEmojis()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 监听外部visible变化
watch(() => props.visible, (newValue) => {
visible.value = newValue
})
</script>
<style scoped>
.emoji-picker-content {
@apply w-full;
}
.emoji-search {
@apply mb-3;
}
.emoji-categories {
@apply flex flex-wrap gap-1 mb-3 border-b border-gray-200 pb-2;
}
.category-tab {
@apply flex items-center space-x-1 px-2 py-1 rounded text-xs cursor-pointer hover:bg-gray-100 transition-colors;
}
.category-tab.active {
@apply bg-blue-100 text-blue-600;
}
.category-icon {
@apply text-sm;
}
.category-name {
@apply hidden sm:inline;
}
.emoji-list {
@apply max-h-64 overflow-y-auto;
}
.empty-state {
@apply text-center py-8;
}
.emoji-grid {
@apply grid grid-cols-8 gap-1;
}
.emoji-item {
@apply w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 cursor-pointer transition-colors;
}
.emoji-char {
@apply text-lg leading-none;
}
.recent-emojis {
@apply mt-3 pt-3 border-t border-gray-200;
}
.recent-title {
@apply text-xs text-gray-600 mb-2 font-medium;
}
/* 自定义滚动条 */
.emoji-list::-webkit-scrollbar {
@apply w-1;
}
.emoji-list::-webkit-scrollbar-track {
@apply bg-gray-100 rounded;
}
.emoji-list::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded hover:bg-gray-400;
}
</style>
<style>
.emoji-popover {
padding: 12px !important;
}
</style>
@@ -0,0 +1,467 @@
<template>
<div v-if="hasError" class="error-boundary">
<div class="error-container">
<!-- 错误图标 -->
<div class="error-icon">
<el-icon size="64" class="text-red-500">
<WarningFilled />
</el-icon>
</div>
<!-- 错误信息 -->
<div class="error-content">
<h2 class="error-title">{{ errorTitle }}</h2>
<p class="error-message">{{ errorMessage }}</p>
<!-- 错误详情开发环境 -->
<div v-if="showDetails && errorDetails" class="error-details">
<el-collapse>
<el-collapse-item title="错误详情" name="details">
<pre class="error-stack">{{ errorDetails }}</pre>
</el-collapse-item>
</el-collapse>
</div>
<!-- 操作按钮 -->
<div class="error-actions">
<el-button type="primary" @click="handleRetry">
<el-icon class="mr-2"><Refresh /></el-icon>
重试
</el-button>
<el-button @click="handleGoHome">
<el-icon class="mr-2"><HomeFilled /></el-icon>
返回首页
</el-button>
<el-button v-if="showReportButton" @click="handleReport">
<el-icon class="mr-2"><Warning /></el-icon>
报告问题
</el-button>
</div>
<!-- 建议操作 -->
<div class="error-suggestions">
<h4 class="suggestions-title">您可以尝试</h4>
<ul class="suggestions-list">
<li>刷新页面重新加载</li>
<li>检查网络连接是否正常</li>
<li>清除浏览器缓存和Cookie</li>
<li>如果问题持续存在请联系技术支持</li>
</ul>
</div>
</div>
</div>
<!-- 错误报告对话框 -->
<el-dialog
v-model="showReportDialog"
title="报告问题"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="reportForm" label-width="80px">
<el-form-item label="问题描述">
<el-input
v-model="reportForm.description"
type="textarea"
:rows="4"
placeholder="请描述您遇到的问题..."
/>
</el-form-item>
<el-form-item label="联系方式">
<el-input
v-model="reportForm.contact"
placeholder="邮箱或电话(可选)"
/>
</el-form-item>
<el-form-item label="包含错误信息">
<el-switch v-model="reportForm.includeError" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showReportDialog = false">取消</el-button>
<el-button type="primary" @click="submitReport" :loading="submitting">
提交报告
</el-button>
</template>
</el-dialog>
</div>
<!-- 正常内容 -->
<slot v-else />
</template>
<script setup lang="ts">
import { ref, reactive, computed, onErrorCaptured, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
WarningFilled,
Refresh,
HomeFilled,
Warning
} from '@element-plus/icons-vue'
interface Props {
// 是否显示错误详情
showDetails?: boolean
// 是否显示报告按钮
showReportButton?: boolean
// 自定义错误标题
customTitle?: string
// 自定义错误消息
customMessage?: string
// 重试回调
onRetry?: () => void
// 错误回调
onError?: (error: Error, instance: any, info: string) => void
}
const props = withDefaults(defineProps<Props>(), {
showDetails: process.env.NODE_ENV === 'development',
showReportButton: true,
customTitle: '',
customMessage: '',
onRetry: undefined,
onError: undefined
})
interface Emits {
(e: 'error', error: Error): void
(e: 'retry'): void
}
const emit = defineEmits<Emits>()
const router = useRouter()
// 响应式数据
const hasError = ref(false)
const errorInfo = ref<Error | null>(null)
const errorInstance = ref<any>(null)
const errorContext = ref('')
const showReportDialog = ref(false)
const submitting = ref(false)
// 报告表单
const reportForm = reactive({
description: '',
contact: '',
includeError: true
})
// 计算属性
const errorTitle = computed(() => {
if (props.customTitle) return props.customTitle
if (errorInfo.value) {
if (errorInfo.value.name === 'ChunkLoadError') {
return '资源加载失败'
}
if (errorInfo.value.name === 'NetworkError') {
return '网络连接错误'
}
if (errorInfo.value.message.includes('timeout')) {
return '请求超时'
}
}
return '页面出现错误'
})
const errorMessage = computed(() => {
if (props.customMessage) return props.customMessage
if (errorInfo.value) {
if (errorInfo.value.name === 'ChunkLoadError') {
return '页面资源加载失败,可能是网络问题或版本更新导致的。'
}
if (errorInfo.value.name === 'NetworkError') {
return '网络连接出现问题,请检查您的网络设置。'
}
if (errorInfo.value.message.includes('timeout')) {
return '请求处理时间过长,请稍后重试。'
}
}
return '页面运行时出现了意外错误,我们正在努力修复。'
})
const errorDetails = computed(() => {
if (!errorInfo.value) return ''
return `${errorInfo.value.name}: ${errorInfo.value.message}\n\n${errorInfo.value.stack || ''}`
})
// 错误捕获
onErrorCaptured((error: Error, instance: any, info: string) => {
console.error('ErrorBoundary caught error:', error)
console.error('Error info:', info)
console.error('Component instance:', instance)
hasError.value = true
errorInfo.value = error
errorInstance.value = instance
errorContext.value = info
// 调用自定义错误处理
if (props.onError) {
props.onError(error, instance, info)
}
// 发送错误事件
emit('error', error)
// 错误上报
reportError(error, info)
// 阻止错误继续传播
return false
})
// 全局错误处理
onMounted(() => {
// 捕获未处理的Promise错误
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason)
const error = new Error(event.reason?.message || 'Unhandled promise rejection')
error.name = 'UnhandledPromiseRejection'
hasError.value = true
errorInfo.value = error
errorContext.value = 'unhandledrejection'
reportError(error, 'unhandledrejection')
})
// 捕获资源加载错误
window.addEventListener('error', (event) => {
if (event.target !== window) {
console.error('Resource load error:', event)
const error = new Error(`Failed to load resource: ${event.target}`)
error.name = 'ResourceLoadError'
// 对于资源加载错误,可以选择不显示错误边界
// 而是显示一个小的错误提示
ElMessage.error('部分资源加载失败,页面可能显示异常')
reportError(error, 'resource-load')
}
}, true)
})
// 方法
const handleRetry = () => {
if (props.onRetry) {
props.onRetry()
} else {
// 默认重试:重新加载页面
window.location.reload()
}
emit('retry')
}
const handleGoHome = () => {
router.push('/')
}
const handleReport = () => {
showReportDialog.value = true
// 预填充错误信息
if (errorInfo.value && reportForm.includeError) {
reportForm.description = `错误类型: ${errorInfo.value.name}\n错误消息: ${errorInfo.value.message}\n发生时间: ${new Date().toLocaleString()}`
}
}
const submitReport = async () => {
if (!reportForm.description.trim()) {
ElMessage.warning('请描述您遇到的问题')
return
}
try {
submitting.value = true
const reportData = {
description: reportForm.description,
contact: reportForm.contact,
error: reportForm.includeError ? {
name: errorInfo.value?.name,
message: errorInfo.value?.message,
stack: errorInfo.value?.stack,
context: errorContext.value
} : null,
userAgent: navigator.userAgent,
url: window.location.href,
timestamp: new Date().toISOString()
}
// 发送错误报告到服务器
await fetch('/api/error-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(reportData)
})
ElMessage.success('问题报告已提交,感谢您的反馈')
showReportDialog.value = false
// 重置表单
reportForm.description = ''
reportForm.contact = ''
reportForm.includeError = true
} catch (error) {
console.error('Failed to submit error report:', error)
ElMessage.error('提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
const reportError = (error: Error, context: string) => {
// 错误上报到监控系统
try {
// 这里可以集成第三方错误监控服务
// 如 Sentry, LogRocket, Bugsnag 等
const errorData = {
name: error.name,
message: error.message,
stack: error.stack,
context,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString()
}
// 发送到错误监控服务
if (window.Sentry) {
window.Sentry.captureException(error, {
contexts: {
errorBoundary: {
context,
componentStack: errorContext.value
}
}
})
}
// 或者发送到自己的错误收集接口
fetch('/api/error-log', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(errorData)
}).catch(err => {
console.error('Failed to report error:', err)
})
} catch (reportError) {
console.error('Error reporting failed:', reportError)
}
}
// 重置错误状态
const resetError = () => {
hasError.value = false
errorInfo.value = null
errorInstance.value = null
errorContext.value = ''
}
// 暴露方法
defineExpose({
resetError,
hasError: () => hasError.value,
getError: () => errorInfo.value
})
</script>
<style scoped>
.error-boundary {
@apply min-h-screen flex items-center justify-center bg-gray-50 p-4;
}
.error-container {
@apply max-w-2xl w-full bg-white rounded-lg shadow-lg p-8 text-center;
}
.error-icon {
@apply mb-6;
}
.error-content {
@apply space-y-6;
}
.error-title {
@apply text-2xl font-bold text-gray-900 mb-4;
}
.error-message {
@apply text-gray-600 text-lg leading-relaxed;
}
.error-details {
@apply text-left;
}
.error-stack {
@apply bg-gray-100 p-4 rounded text-sm font-mono text-gray-800 overflow-auto max-h-64;
}
.error-actions {
@apply flex flex-wrap justify-center gap-4;
}
.error-suggestions {
@apply text-left bg-blue-50 p-4 rounded-lg;
}
.suggestions-title {
@apply text-sm font-semibold text-blue-900 mb-2;
}
.suggestions-list {
@apply text-sm text-blue-800 space-y-1;
}
.suggestions-list li {
@apply flex items-start;
}
.suggestions-list li::before {
content: "•";
@apply text-blue-500 mr-2 flex-shrink-0;
}
@media (max-width: 640px) {
.error-container {
@apply p-6;
}
.error-title {
@apply text-xl;
}
.error-message {
@apply text-base;
}
.error-actions {
@apply flex-col;
}
}
</style>
@@ -0,0 +1,514 @@
<template>
<div class="notification-center">
<!-- 通知按钮 -->
<el-popover
:visible="visible"
:width="360"
trigger="manual"
placement="bottom-end"
popper-class="notification-popover"
@hide="handleHide"
>
<template #reference>
<div class="notification-trigger" @click="toggleNotifications">
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
<el-button circle>
<el-icon :size="18">
<Bell />
</el-icon>
</el-button>
</el-badge>
</div>
</template>
<div class="notification-content">
<!-- 头部 -->
<div class="notification-header">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">通知</h3>
<div class="flex items-center space-x-2">
<el-button
v-if="unreadCount > 0"
size="small"
text
@click="markAllAsRead"
>
全部已读
</el-button>
<el-button size="small" text @click="clearAll">
清空
</el-button>
</div>
</div>
</div>
<!-- 筛选标签 -->
<div class="notification-filters">
<div class="filter-tabs">
<div
v-for="filter in filters"
:key="filter.key"
class="filter-tab"
:class="{ active: activeFilter === filter.key }"
@click="switchFilter(filter.key)"
>
{{ filter.label }}
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
</div>
</div>
</div>
<!-- 通知列表 -->
<div class="notification-list">
<div v-if="filteredNotifications.length === 0" class="empty-state">
<div class="empty-icon">
<el-icon size="32" class="text-gray-400">
<Bell />
</el-icon>
</div>
<p class="empty-text">暂无通知</p>
</div>
<div v-else class="notification-items">
<div
v-for="notification in filteredNotifications"
:key="notification.id"
class="notification-item"
:class="{ unread: !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="notification-icon">
<div
class="icon-wrapper"
:class="getNotificationIconClass(notification.type)"
>
<el-icon>
<component :is="getNotificationIcon(notification.type)" />
</el-icon>
</div>
</div>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ formatTime(notification.createTime) }}</div>
</div>
<div class="notification-actions">
<el-dropdown @command="handleAction">
<el-button circle size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="!notification.read"
:command="`read_${notification.id}`"
>
标记已读
</el-dropdown-item>
<el-dropdown-item
v-else
:command="`unread_${notification.id}`"
>
标记未读
</el-dropdown-item>
<el-dropdown-item
:command="`delete_${notification.id}`"
divided
>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div v-if="hasMore" class="notification-footer">
<el-button text @click="loadMore" :loading="loading">
加载更多
</el-button>
</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Bell,
MoreFilled,
ChatDotRound,
User,
Setting,
Warning,
InfoFilled
} from '@element-plus/icons-vue'
import { formatRelativeTime } from '@/utils/format'
import { useNotificationStore } from '@/stores/notification'
interface NotificationItem {
id: string
type: 'message' | 'system' | 'user' | 'warning' | 'info'
title: string
message: string
read: boolean
createTime: number
data?: any
}
// 状态管理
const notificationStore = useNotificationStore()
// 响应式数据
const visible = ref(false)
const loading = ref(false)
const activeFilter = ref('all')
// 模拟通知数据
const notifications = ref<NotificationItem[]>([
{
id: '1',
type: 'message',
title: 'AI助手回复',
message: '您的问题已经得到回复,请查看',
read: false,
createTime: Date.now() - 1000 * 60 * 5
},
{
id: '2',
type: 'system',
title: '系统更新',
message: '系统已更新到最新版本 v1.2.0',
read: false,
createTime: Date.now() - 1000 * 60 * 60
},
{
id: '3',
type: 'user',
title: '资料完善提醒',
message: '完善个人资料可以获得更好的服务体验',
read: true,
createTime: Date.now() - 1000 * 60 * 60 * 24
}
])
const hasMore = ref(true)
// 筛选选项
const filters = computed(() => [
{ key: 'all', label: '全部', count: notifications.value.length },
{ key: 'unread', label: '未读', count: unreadCount.value },
{ key: 'message', label: '消息', count: notifications.value.filter(n => n.type === 'message').length },
{ key: 'system', label: '系统', count: notifications.value.filter(n => n.type === 'system').length }
])
// 计算属性
const unreadCount = computed(() => {
return notifications.value.filter(n => !n.read).length
})
const filteredNotifications = computed(() => {
let filtered = notifications.value
switch (activeFilter.value) {
case 'unread':
filtered = filtered.filter(n => !n.read)
break
case 'message':
filtered = filtered.filter(n => n.type === 'message')
break
case 'system':
filtered = filtered.filter(n => n.type === 'system')
break
case 'user':
filtered = filtered.filter(n => n.type === 'user')
break
}
return filtered.sort((a, b) => b.createTime - a.createTime)
})
// 方法
const toggleNotifications = () => {
visible.value = !visible.value
}
const handleHide = () => {
visible.value = false
}
const switchFilter = (filterKey: string) => {
activeFilter.value = filterKey
}
const handleNotificationClick = (notification: NotificationItem) => {
// 标记为已读
if (!notification.read) {
markAsRead(notification.id)
}
// 处理点击事件
switch (notification.type) {
case 'message':
// 跳转到聊天页面
break
case 'system':
// 显示系统消息详情
break
case 'user':
// 跳转到个人资料页面
break
}
visible.value = false
}
const markAsRead = (notificationId: string) => {
const notification = notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.read = true
}
}
const markAsUnread = (notificationId: string) => {
const notification = notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.read = false
}
}
const markAllAsRead = () => {
notifications.value.forEach(n => {
n.read = true
})
ElMessage.success('已标记全部为已读')
}
const deleteNotification = (notificationId: string) => {
const index = notifications.value.findIndex(n => n.id === notificationId)
if (index > -1) {
notifications.value.splice(index, 1)
ElMessage.success('通知已删除')
}
}
const clearAll = () => {
notifications.value = []
ElMessage.success('已清空所有通知')
}
const loadMore = () => {
loading.value = true
// 模拟加载更多
setTimeout(() => {
loading.value = false
hasMore.value = false
ElMessage.info('没有更多通知了')
}, 1000)
}
const handleAction = (command: string) => {
const [action, notificationId] = command.split('_')
switch (action) {
case 'read':
markAsRead(notificationId)
break
case 'unread':
markAsUnread(notificationId)
break
case 'delete':
deleteNotification(notificationId)
break
}
}
const getNotificationIcon = (type: string) => {
switch (type) {
case 'message':
return ChatDotRound
case 'system':
return Setting
case 'user':
return User
case 'warning':
return Warning
case 'info':
return InfoFilled
default:
return Bell
}
}
const getNotificationIconClass = (type: string) => {
switch (type) {
case 'message':
return 'text-blue-500 bg-blue-100'
case 'system':
return 'text-green-500 bg-green-100'
case 'user':
return 'text-purple-500 bg-purple-100'
case 'warning':
return 'text-orange-500 bg-orange-100'
case 'info':
return 'text-gray-500 bg-gray-100'
default:
return 'text-gray-500 bg-gray-100'
}
}
const formatTime = (timestamp: number) => {
return formatRelativeTime(timestamp)
}
// 点击外部关闭
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.notification-center') && !target.closest('.notification-popover')) {
visible.value = false
}
}
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.notification-trigger {
@apply cursor-pointer;
}
.notification-content {
@apply w-full;
}
.notification-header {
@apply pb-3 border-b border-gray-200 mb-3;
}
.notification-filters {
@apply mb-3;
}
.filter-tabs {
@apply flex space-x-1;
}
.filter-tab {
@apply px-3 py-1 text-sm rounded-full cursor-pointer transition-colors flex items-center space-x-1;
@apply text-gray-600 hover:bg-gray-100;
}
.filter-tab.active {
@apply bg-blue-100 text-blue-600;
}
.filter-count {
@apply bg-current text-white rounded-full px-1.5 py-0.5 text-xs min-w-[1.25rem] text-center;
}
.notification-list {
@apply max-h-96 overflow-y-auto;
}
.empty-state {
@apply text-center py-8;
}
.empty-icon {
@apply mb-3;
}
.empty-text {
@apply text-gray-500 text-sm;
}
.notification-items {
@apply space-y-1;
}
.notification-item {
@apply flex items-start space-x-3 p-3 rounded-lg cursor-pointer transition-colors;
@apply hover:bg-gray-50;
}
.notification-item.unread {
@apply bg-blue-50 border-l-4 border-blue-500;
}
.notification-icon {
@apply flex-shrink-0;
}
.icon-wrapper {
@apply w-8 h-8 rounded-full flex items-center justify-center;
}
.notification-content {
@apply flex-1 min-w-0;
}
.notification-title {
@apply text-sm font-medium text-gray-900 mb-1;
}
.notification-message {
@apply text-sm text-gray-600 mb-1 line-clamp-2;
}
.notification-time {
@apply text-xs text-gray-500;
}
.notification-actions {
@apply flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity;
}
.notification-item:hover .notification-actions {
@apply opacity-100;
}
.notification-footer {
@apply text-center pt-3 border-t border-gray-200 mt-3;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 自定义滚动条 */
.notification-list::-webkit-scrollbar {
@apply w-1;
}
.notification-list::-webkit-scrollbar-track {
@apply bg-gray-100 rounded;
}
.notification-list::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded hover:bg-gray-400;
}
</style>
<style>
.notification-popover {
padding: 16px !important;
}
</style>
@@ -0,0 +1,373 @@
<template>
<div class="file-upload">
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:data="uploadData"
:multiple="multiple"
:accept="accept"
:limit="limit"
:file-list="fileList"
:before-upload="handleBeforeUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:on-remove="handleRemove"
:on-exceed="handleExceed"
:auto-upload="autoUpload"
:show-file-list="showFileList"
:drag="drag"
:disabled="disabled"
class="upload-component"
>
<!-- 拖拽上传区域 -->
<div v-if="drag" class="upload-dragger">
<el-icon class="upload-icon">
<UploadFilled />
</el-icon>
<div class="upload-text">
<p class="upload-title">将文件拖拽到此处<em>点击上传</em></p>
<p class="upload-hint">{{ uploadHint }}</p>
</div>
</div>
<!-- 按钮上传 -->
<template v-else>
<el-button v-if="!hideButton" :type="buttonType" :disabled="disabled">
<el-icon class="mr-2">
<component :is="buttonIcon" />
</el-icon>
{{ buttonText }}
</el-button>
<!-- 自定义触发器 -->
<slot v-else name="trigger" />
</template>
<!-- 提示信息 -->
<template #tip>
<div v-if="showTip" class="upload-tip">
<p class="text-sm text-gray-500">{{ uploadHint }}</p>
</div>
</template>
</el-upload>
<!-- 上传进度 -->
<div v-if="uploading && showProgress" class="upload-progress mt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">上传进度</span>
<span class="text-sm text-gray-600">{{ uploadPercent }}%</span>
</div>
<el-progress
:percentage="uploadPercent"
:status="uploadStatus"
:stroke-width="6"
/>
</div>
<!-- 文件列表 -->
<div v-if="!showFileList && fileList.length > 0" class="file-list mt-4">
<div
v-for="(file, index) in fileList"
:key="file.uid"
class="file-item flex items-center justify-between p-3 bg-gray-50 rounded-lg mb-2"
>
<div class="flex items-center space-x-3">
<el-icon class="text-gray-400">
<Document />
</el-icon>
<div>
<p class="text-sm font-medium text-gray-900">{{ file.name }}</p>
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<el-button
v-if="file.status === 'ready' && !autoUpload"
size="small"
type="primary"
@click="uploadFile(file)"
>
上传
</el-button>
<el-button
size="small"
type="danger"
@click="removeFile(index)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled, Upload, Document, Picture } from '@element-plus/icons-vue'
import type { UploadInstance, UploadProps, UploadUserFile, UploadFile } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { UPLOAD_CONFIG } from '@/config/constants'
import { formatFileSize } from '@/utils/format'
interface Props {
// 上传配置
action?: string
multiple?: boolean
accept?: string
limit?: number
maxSize?: number
autoUpload?: boolean
// 显示配置
drag?: boolean
showFileList?: boolean
showProgress?: boolean
showTip?: boolean
hideButton?: boolean
// 按钮配置
buttonText?: string
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
buttonIcon?: any
// 状态
disabled?: boolean
// 文件类型
fileType?: 'image' | 'document' | 'video' | 'audio' | 'any'
// 自定义数据
data?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
action: '/api/upload',
multiple: false,
accept: '',
limit: 5,
maxSize: 10 * 1024 * 1024, // 10MB
autoUpload: true,
drag: false,
showFileList: true,
showProgress: true,
showTip: true,
hideButton: false,
buttonText: '选择文件',
buttonType: 'primary',
buttonIcon: Upload,
disabled: false,
fileType: 'any',
data: () => ({})
})
interface Emits {
(e: 'success', response: any, file: UploadFile): void
(e: 'error', error: any, file: UploadFile): void
(e: 'progress', event: any, file: UploadFile): void
(e: 'remove', file: UploadFile): void
(e: 'change', fileList: UploadUserFile[]): void
}
const emit = defineEmits<Emits>()
// 状态管理
const authStore = useAuthStore()
// 响应式数据
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([])
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadStatus = ref<'success' | 'exception' | undefined>()
// 计算属性
const uploadUrl = computed(() => {
return props.action || UPLOAD_CONFIG.DEFAULT_UPLOAD_URL
})
const uploadHeaders = computed(() => {
return {
Authorization: `Bearer ${authStore.token}`,
'X-Requested-With': 'XMLHttpRequest'
}
})
const uploadData = computed(() => {
return {
type: props.fileType,
...props.data
}
})
const acceptTypes = computed(() => {
if (props.accept) return props.accept
switch (props.fileType) {
case 'image':
return UPLOAD_CONFIG.IMAGE_TYPES.join(',')
case 'document':
return UPLOAD_CONFIG.DOCUMENT_TYPES.join(',')
case 'video':
return UPLOAD_CONFIG.VIDEO_TYPES.join(',')
case 'audio':
return UPLOAD_CONFIG.AUDIO_TYPES.join(',')
default:
return ''
}
})
const uploadHint = computed(() => {
const sizeText = formatFileSize(props.maxSize)
const limitText = props.limit > 1 ? `最多${props.limit}个文件,` : ''
switch (props.fileType) {
case 'image':
return `${limitText}支持 JPG、PNG、GIF 格式,单个文件不超过 ${sizeText}`
case 'document':
return `${limitText}支持 PDF、DOC、DOCX、XLS、XLSX 格式,单个文件不超过 ${sizeText}`
case 'video':
return `${limitText}支持 MP4、AVI、MOV 格式,单个文件不超过 ${sizeText}`
case 'audio':
return `${limitText}支持 MP3、WAV、AAC 格式,单个文件不超过 ${sizeText}`
default:
return `${limitText}单个文件不超过 ${sizeText}`
}
})
// 方法
const handleBeforeUpload = (file: File) => {
// 检查文件类型
if (acceptTypes.value && !isValidFileType(file)) {
ElMessage.error('文件类型不支持')
return false
}
// 检查文件大小
if (file.size > props.maxSize) {
ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
return false
}
uploading.value = true
uploadPercent.value = 0
uploadStatus.value = undefined
return true
}
const handleProgress = (event: any, file: UploadFile) => {
uploadPercent.value = Math.round(event.percent)
emit('progress', event, file)
}
const handleSuccess = (response: any, file: UploadFile) => {
uploading.value = false
uploadPercent.value = 100
uploadStatus.value = 'success'
ElMessage.success('文件上传成功')
emit('success', response, file)
}
const handleError = (error: any, file: UploadFile) => {
uploading.value = false
uploadStatus.value = 'exception'
console.error('文件上传失败:', error)
ElMessage.error('文件上传失败,请重试')
emit('error', error, file)
}
const handleRemove = (file: UploadFile) => {
emit('remove', file)
}
const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
}
const isValidFileType = (file: File): boolean => {
if (!acceptTypes.value) return true
const types = acceptTypes.value.split(',').map(type => type.trim())
return types.some(type => {
if (type.includes('*')) {
const [mainType] = type.split('/')
return file.type.startsWith(mainType + '/')
}
return file.type === type
})
}
const uploadFile = (file: UploadUserFile) => {
uploadRef.value?.submit()
}
const removeFile = (index: number) => {
fileList.value.splice(index, 1)
emit('change', fileList.value)
}
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
emit('change', fileList.value)
}
// 监听文件列表变化
watch(fileList, (newList) => {
emit('change', newList)
}, { deep: true })
// 暴露方法
defineExpose({
clearFiles,
uploadFile,
removeFile
})
</script>
<style scoped>
.upload-dragger {
@apply border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors;
}
.upload-icon {
@apply text-4xl text-gray-400 mb-4;
}
.upload-title {
@apply text-lg text-gray-600 mb-2;
}
.upload-title em {
@apply text-blue-500 not-italic;
}
.upload-hint {
@apply text-sm text-gray-500;
}
.file-item {
transition: all 0.3s ease;
}
.file-item:hover {
@apply bg-gray-100;
}
:deep(.el-upload-dragger) {
border: none !important;
background: transparent !important;
}
:deep(.el-upload-dragger:hover) {
border: none !important;
}
</style>
@@ -0,0 +1,471 @@
<template>
<div class="image-upload">
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:data="uploadData"
:multiple="multiple"
:accept="acceptTypes"
:limit="limit"
:file-list="fileList"
:before-upload="handleBeforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:on-remove="handleRemove"
:on-exceed="handleExceed"
:auto-upload="autoUpload"
:show-file-list="false"
:disabled="disabled"
list-type="picture-card"
class="image-upload-component"
>
<!-- 上传按钮 -->
<div v-if="fileList.length < limit" class="upload-trigger">
<el-icon class="upload-icon">
<Plus />
</el-icon>
<div class="upload-text">{{ buttonText }}</div>
</div>
</el-upload>
<!-- 图片预览列表 -->
<div v-if="fileList.length > 0" class="image-list">
<div
v-for="(file, index) in fileList"
:key="file.uid || index"
class="image-item"
>
<div class="image-wrapper">
<img
:src="getImageUrl(file)"
:alt="file.name"
class="image-preview"
@click="previewImage(file, index)"
/>
<!-- 遮罩层 -->
<div class="image-overlay">
<div class="overlay-actions">
<el-button
circle
size="small"
@click="previewImage(file, index)"
>
<el-icon><ZoomIn /></el-icon>
</el-button>
<el-button
v-if="!disabled"
circle
size="small"
type="danger"
@click="removeImage(index)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<!-- 上传进度 -->
<div v-if="file.status === 'uploading'" class="upload-progress">
<el-progress
type="circle"
:percentage="file.percentage || 0"
:width="40"
/>
</div>
<!-- 上传状态 -->
<div v-if="file.status === 'success'" class="upload-status success">
<el-icon><Check /></el-icon>
</div>
<div v-if="file.status === 'fail'" class="upload-status error">
<el-icon><Close /></el-icon>
</div>
</div>
<!-- 图片信息 -->
<div v-if="showInfo" class="image-info">
<p class="image-name">{{ file.name }}</p>
<p class="image-size">{{ formatFileSize(file.size) }}</p>
</div>
</div>
</div>
<!-- 图片预览对话框 -->
<el-dialog
v-model="previewVisible"
title="图片预览"
width="80%"
:close-on-click-modal="true"
append-to-body
>
<div class="preview-container">
<img
v-if="previewUrl"
:src="previewUrl"
:alt="previewName"
class="preview-image"
/>
</div>
<template #footer>
<div class="preview-footer">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="downloadImage">下载</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, ZoomIn, Delete, Check, Close } from '@element-plus/icons-vue'
import type { UploadInstance, UploadUserFile, UploadFile } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { UPLOAD_CONFIG } from '@/config/constants'
import { formatFileSize } from '@/utils/format'
interface Props {
// 上传配置
action?: string
multiple?: boolean
limit?: number
maxSize?: number
autoUpload?: boolean
// 显示配置
showInfo?: boolean
buttonText?: string
// 状态
disabled?: boolean
// 图片尺寸限制
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
// 自定义数据
data?: Record<string, any>
// 默认图片列表
defaultFileList?: UploadUserFile[]
}
const props = withDefaults(defineProps<Props>(), {
action: '/api/upload/image',
multiple: true,
limit: 9,
maxSize: 5 * 1024 * 1024, // 5MB
autoUpload: true,
showInfo: false,
buttonText: '上传图片',
disabled: false,
data: () => ({}),
defaultFileList: () => []
})
interface Emits {
(e: 'success', response: any, file: UploadFile): void
(e: 'error', error: any, file: UploadFile): void
(e: 'remove', file: UploadFile, index: number): void
(e: 'change', fileList: UploadUserFile[]): void
(e: 'preview', file: UploadUserFile, index: number): void
}
const emit = defineEmits<Emits>()
// 状态管理
const authStore = useAuthStore()
// 响应式数据
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([...props.defaultFileList])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewName = ref('')
const currentPreviewIndex = ref(0)
// 计算属性
const uploadUrl = computed(() => {
return props.action || UPLOAD_CONFIG.IMAGE_UPLOAD_URL
})
const uploadHeaders = computed(() => {
return {
Authorization: `Bearer ${authStore.token}`,
'X-Requested-With': 'XMLHttpRequest'
}
})
const uploadData = computed(() => {
return {
type: 'image',
...props.data
}
})
const acceptTypes = computed(() => {
return UPLOAD_CONFIG.IMAGE_TYPES.join(',')
})
// 方法
const handleBeforeUpload = async (file: File) => {
// 检查文件类型
if (!isValidImageType(file)) {
ElMessage.error('只支持 JPG、PNG、GIF 格式的图片')
return false
}
// 检查文件大小
if (file.size > props.maxSize) {
ElMessage.error(`图片大小不能超过 ${formatFileSize(props.maxSize)}`)
return false
}
// 检查图片尺寸
if (props.minWidth || props.minHeight || props.maxWidth || props.maxHeight) {
const isValidSize = await validateImageSize(file)
if (!isValidSize) {
return false
}
}
return true
}
const handleSuccess = (response: any, file: UploadFile) => {
ElMessage.success('图片上传成功')
// 更新文件列表中的URL
const index = fileList.value.findIndex(item => item.uid === file.uid)
if (index > -1) {
fileList.value[index].url = response.url
fileList.value[index].status = 'success'
}
emit('success', response, file)
emit('change', fileList.value)
}
const handleError = (error: any, file: UploadFile) => {
console.error('图片上传失败:', error)
ElMessage.error('图片上传失败,请重试')
// 更新文件状态
const index = fileList.value.findIndex(item => item.uid === file.uid)
if (index > -1) {
fileList.value[index].status = 'fail'
}
emit('error', error, file)
}
const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex(item => item.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
emit('remove', file, index)
emit('change', fileList.value)
}
}
const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片`)
}
const removeImage = (index: number) => {
const file = fileList.value[index]
fileList.value.splice(index, 1)
emit('remove', file as UploadFile, index)
emit('change', fileList.value)
}
const previewImage = (file: UploadUserFile, index: number) => {
previewUrl.value = getImageUrl(file)
previewName.value = file.name || '图片预览'
currentPreviewIndex.value = index
previewVisible.value = true
emit('preview', file, index)
}
const getImageUrl = (file: UploadUserFile): string => {
if (file.url) {
return file.url
}
if (file.raw) {
return URL.createObjectURL(file.raw)
}
return ''
}
const isValidImageType = (file: File): boolean => {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return validTypes.includes(file.type)
}
const validateImageSize = (file: File): Promise<boolean> => {
return new Promise((resolve) => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
const { width, height } = img
let valid = true
let message = ''
if (props.minWidth && width < props.minWidth) {
valid = false
message = `图片宽度不能小于 ${props.minWidth}px`
} else if (props.maxWidth && width > props.maxWidth) {
valid = false
message = `图片宽度不能大于 ${props.maxWidth}px`
} else if (props.minHeight && height < props.minHeight) {
valid = false
message = `图片高度不能小于 ${props.minHeight}px`
} else if (props.maxHeight && height > props.maxHeight) {
valid = false
message = `图片高度不能大于 ${props.maxHeight}px`
}
if (!valid) {
ElMessage.error(message)
}
resolve(valid)
}
img.onerror = () => {
URL.revokeObjectURL(url)
ElMessage.error('图片格式不正确')
resolve(false)
}
img.src = url
})
}
const downloadImage = () => {
if (previewUrl.value) {
const link = document.createElement('a')
link.href = previewUrl.value
link.download = previewName.value
link.click()
}
}
const clearImages = () => {
uploadRef.value?.clearFiles()
fileList.value = []
emit('change', fileList.value)
}
// 暴露方法
defineExpose({
clearImages,
removeImage,
previewImage
})
</script>
<style scoped>
.image-upload-component {
@apply w-full;
}
.upload-trigger {
@apply w-full h-full flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 transition-colors cursor-pointer;
min-height: 120px;
}
.upload-icon {
@apply text-2xl text-gray-400 mb-2;
}
.upload-text {
@apply text-sm text-gray-500;
}
.image-list {
@apply grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4;
}
.image-item {
@apply relative;
}
.image-wrapper {
@apply relative overflow-hidden rounded-lg border border-gray-200 aspect-square;
}
.image-preview {
@apply w-full h-full object-cover cursor-pointer;
}
.image-overlay {
@apply absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity;
}
.overlay-actions {
@apply flex space-x-2;
}
.upload-progress {
@apply absolute inset-0 flex items-center justify-center bg-white bg-opacity-80;
}
.upload-status {
@apply absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-white text-sm;
}
.upload-status.success {
@apply bg-green-500;
}
.upload-status.error {
@apply bg-red-500;
}
.image-info {
@apply mt-2 text-center;
}
.image-name {
@apply text-sm text-gray-900 truncate;
}
.image-size {
@apply text-xs text-gray-500;
}
.preview-container {
@apply text-center;
}
.preview-image {
@apply max-w-full max-h-96 mx-auto;
}
.preview-footer {
@apply flex justify-center space-x-4;
}
:deep(.el-upload--picture-card) {
@apply w-full h-auto;
}
:deep(.el-upload-list--picture-card) {
@apply hidden;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
/**
* 聊天功能组合式API
* 管理WebSocket连接、消息发送接收等
*/
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getWebSocketInstance } from '@/utils/websocket'
import { useAuthStore } from '@/stores/auth'
import { WS_SUBSCRIBE_PATHS, WS_SEND_PATHS, MESSAGE_TYPES, SENDER_TYPES } from '@/config/constants'
import type { MessageInfo, WSChatMessage } from '@/types/api'
export function useChat() {
const authStore = useAuthStore()
// 响应式数据
const messages = ref<MessageInfo[]>([])
const isConnected = ref(false)
const isTyping = ref(false)
const currentConversationId = ref<string>('')
const connectionState = ref('DISCONNECTED')
// WebSocket实例
let wsInstance: any = null
let unsubscribeUserMessages: (() => void) | null = null
let unsubscribeChatRoom: (() => void) | null = null
// 计算属性
const sortedMessages = computed(() => {
return [...messages.value].sort((a, b) => a.timestamp - b.timestamp)
})
const lastMessage = computed(() => {
return sortedMessages.value[sortedMessages.value.length - 1]
})
const messageCount = computed(() => messages.value.length)
/**
* 初始化WebSocket连接
*/
const initializeWebSocket = () => {
if (!authStore.token || !authStore.userId) {
console.warn('⚠️ 用户未登录,无法建立WebSocket连接')
return
}
wsInstance = getWebSocketInstance({
onConnect: () => {
isConnected.value = true
connectionState.value = 'CONNECTED'
console.log('✅ 聊天WebSocket连接成功')
// 订阅个人消息
subscribeToUserMessages()
// 订阅聊天室消息
subscribeToChatRoom()
},
onDisconnect: () => {
isConnected.value = false
connectionState.value = 'DISCONNECTED'
console.log('❌ 聊天WebSocket连接断开')
},
onError: (error) => {
connectionState.value = 'ERROR'
console.error('❌ 聊天WebSocket错误:', error)
ElMessage.error('聊天连接出现问题,请刷新页面重试')
},
onTokenExpired: () => {
ElMessage.warning('登录已过期,请重新登录')
authStore.logout()
},
onReconnect: (attempt) => {
connectionState.value = 'CONNECTING'
console.log(`🔄 聊天WebSocket重连中... (第${attempt}次)`)
}
})
// 连接WebSocket
wsInstance.connect(authStore.token)
}
/**
* 订阅个人消息
*/
const subscribeToUserMessages = () => {
if (!wsInstance || !authStore.userId) return
const destination = WS_SUBSCRIBE_PATHS.USER_MESSAGES(authStore.userId)
unsubscribeUserMessages = wsInstance.subscribe(destination, (message: MessageInfo) => {
handleIncomingMessage(message)
})
}
/**
* 订阅聊天室消息
*/
const subscribeToChatRoom = () => {
if (!wsInstance) return
unsubscribeChatRoom = wsInstance.subscribe(WS_SUBSCRIBE_PATHS.CHAT_ROOM, (message: MessageInfo) => {
handleIncomingMessage(message)
})
}
/**
* 处理接收到的消息
*/
const handleIncomingMessage = (message: MessageInfo) => {
// 检查是否已存在该消息(避免重复)
const existingMessage = messages.value.find(m => m.id === message.id)
if (existingMessage) return
// 添加到消息列表
messages.value.push(message)
// 如果是AI回复,显示通知
if (message.senderType === SENDER_TYPES.AI) {
ElMessage.success('收到AI回复')
}
console.log('📨 收到新消息:', message)
}
/**
* 发送消息
*/
const sendMessage = (content: string, type: string = MESSAGE_TYPES.TEXT, metadata?: any) => {
if (!wsInstance || !isConnected.value) {
ElMessage.error('连接已断开,无法发送消息')
return
}
if (!content.trim()) {
ElMessage.warning('消息内容不能为空')
return
}
const messageData: WSChatMessage = {
conversationId: currentConversationId.value,
content: content.trim(),
type: type as any,
metadata
}
try {
wsInstance.send(WS_SEND_PATHS.CHAT_SEND, messageData)
// 添加到本地消息列表(乐观更新)
const localMessage: MessageInfo = {
id: `temp_${Date.now()}`,
conversationId: currentConversationId.value,
content: content.trim(),
type: type as any,
senderId: authStore.userId!,
senderType: SENDER_TYPES.USER,
senderName: authStore.nickname!,
senderAvatar: authStore.avatar,
status: 'sending',
timestamp: Date.now(),
metadata
}
messages.value.push(localMessage)
console.log('📤 发送消息:', messageData)
} catch (error) {
console.error('❌ 发送消息失败:', error)
ElMessage.error('发送消息失败,请重试')
}
}
/**
* 发送图片消息
*/
const sendImageMessage = (imageUrl: string, metadata?: any) => {
sendMessage(imageUrl, MESSAGE_TYPES.IMAGE, {
...metadata,
imageUrl
})
}
/**
* 发送文件消息
*/
const sendFileMessage = (fileUrl: string, fileName: string, fileSize: number) => {
sendMessage(fileUrl, MESSAGE_TYPES.FILE, {
fileUrl,
fileName,
fileSize
})
}
/**
* 发送表情消息
*/
const sendEmojiMessage = (emoji: string) => {
sendMessage(emoji, MESSAGE_TYPES.EMOJI)
}
/**
* 发送正在输入状态
*/
const sendTypingStatus = (isTyping: boolean) => {
if (!wsInstance || !isConnected.value) return
wsInstance.send(WS_SEND_PATHS.TYPING, {
conversationId: currentConversationId.value,
isTyping
})
}
/**
* 设置当前会话ID
*/
const setConversationId = (conversationId: string) => {
currentConversationId.value = conversationId
// 清空之前的消息
messages.value = []
}
/**
* 清空消息
*/
const clearMessages = () => {
messages.value = []
}
/**
* 重连WebSocket
*/
const reconnect = () => {
if (wsInstance) {
wsInstance.disconnect()
}
setTimeout(() => {
initializeWebSocket()
}, 1000)
}
/**
* 断开WebSocket连接
*/
const disconnect = () => {
// 取消订阅
if (unsubscribeUserMessages) {
unsubscribeUserMessages()
unsubscribeUserMessages = null
}
if (unsubscribeChatRoom) {
unsubscribeChatRoom()
unsubscribeChatRoom = null
}
// 断开连接
if (wsInstance) {
wsInstance.disconnect()
wsInstance = null
}
isConnected.value = false
connectionState.value = 'DISCONNECTED'
}
// 生命周期钩子
onMounted(() => {
if (authStore.isLoggedIn) {
initializeWebSocket()
}
})
onUnmounted(() => {
disconnect()
})
return {
// 响应式数据
messages: sortedMessages,
isConnected,
isTyping,
connectionState,
currentConversationId,
// 计算属性
lastMessage,
messageCount,
// 方法
sendMessage,
sendImageMessage,
sendFileMessage,
sendEmojiMessage,
sendTypingStatus,
setConversationId,
clearMessages,
reconnect,
disconnect,
initializeWebSocket
}
}
+380
View File
@@ -0,0 +1,380 @@
/**
* 日记功能组合式API
* 管理日记的创建、编辑、删除、草稿等功能
*/
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { diaryApi } from '@/api/diary'
import { STORAGE_KEYS } from '@/config/constants'
import storage from '@/utils/storage'
import type {
DiaryPost,
PublishDiaryRequest,
GetUserDiariesRequest
} from '@/types/api'
export function useDiary() {
// 响应式数据
const loading = ref(false)
const publishing = ref(false)
const diaries = ref<DiaryPost[]>([])
const currentDiary = ref<DiaryPost | null>(null)
const drafts = ref<DiaryPost[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
// 计算属性
const hasMore = computed(() => {
return diaries.value.length < total.value
})
const draftCount = computed(() => drafts.value.length)
/**
* 获取用户日记列表
*/
const fetchUserDiaries = async (params: GetUserDiariesRequest = {}) => {
try {
loading.value = true
const requestParams = {
page: currentPage.value,
pageSize: pageSize.value,
...params
}
const response = await diaryApi.getUserDiaries(requestParams)
if (currentPage.value === 1) {
diaries.value = response.list
} else {
diaries.value.push(...response.list)
}
total.value = response.total
return response
} catch (error) {
console.error('获取日记列表失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 加载更多日记
*/
const loadMoreDiaries = async (params: GetUserDiariesRequest = {}) => {
if (!hasMore.value || loading.value) return
currentPage.value++
await fetchUserDiaries(params)
}
/**
* 刷新日记列表
*/
const refreshDiaries = async (params: GetUserDiariesRequest = {}) => {
currentPage.value = 1
await fetchUserDiaries(params)
}
/**
* 获取日记详情
*/
const fetchDiaryDetail = async (diaryId: string) => {
try {
loading.value = true
currentDiary.value = await diaryApi.getDiaryDetail(diaryId)
return currentDiary.value
} catch (error) {
console.error('获取日记详情失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 发布日记
*/
const publishDiary = async (data: PublishDiaryRequest) => {
try {
// 验证必填字段
if (!data.title.trim()) {
throw new Error('请输入日记标题')
}
if (!data.content.trim()) {
throw new Error('请输入日记内容')
}
if (!data.emotion) {
throw new Error('请选择情绪类型')
}
if (data.mood < 1 || data.mood > 10) {
throw new Error('心情指数必须在1-10之间')
}
publishing.value = true
const diary = await diaryApi.publish(data)
// 添加到列表开头
diaries.value.unshift(diary)
total.value++
// 清除草稿
clearDraft()
ElMessage.success('日记发布成功')
return diary
} catch (error: any) {
ElMessage.error(error.message || '日记发布失败')
throw error
} finally {
publishing.value = false
}
}
/**
* 更新日记
*/
const updateDiary = async (diaryId: string, data: Partial<PublishDiaryRequest>) => {
try {
const updatedDiary = await diaryApi.updateDiary(diaryId, data)
// 更新列表中的日记
const index = diaries.value.findIndex(d => d.id === diaryId)
if (index > -1) {
diaries.value[index] = updatedDiary
}
// 更新当前日记
if (currentDiary.value?.id === diaryId) {
currentDiary.value = updatedDiary
}
ElMessage.success('日记更新成功')
return updatedDiary
} catch (error: any) {
ElMessage.error(error.message || '日记更新失败')
throw error
}
}
/**
* 删除日记
*/
const deleteDiary = async (diaryId: string) => {
try {
await diaryApi.deleteDiary(diaryId)
// 从列表中移除
const index = diaries.value.findIndex(d => d.id === diaryId)
if (index > -1) {
diaries.value.splice(index, 1)
total.value--
}
// 清除当前日记
if (currentDiary.value?.id === diaryId) {
currentDiary.value = null
}
ElMessage.success('日记删除成功')
} catch (error: any) {
ElMessage.error(error.message || '日记删除失败')
throw error
}
}
/**
* 保存草稿
*/
const saveDraft = async (data: Partial<PublishDiaryRequest>) => {
try {
// 本地保存草稿
storage.set(STORAGE_KEYS.DRAFT_DIARY, data)
// 如果有标题和内容,保存到服务器
if (data.title?.trim() || data.content?.trim()) {
const draft = await diaryApi.saveDraft(data)
// 更新草稿列表
const existingIndex = drafts.value.findIndex(d => d.id === draft.id)
if (existingIndex > -1) {
drafts.value[existingIndex] = draft
} else {
drafts.value.unshift(draft)
}
ElMessage.success('草稿保存成功')
return draft
}
} catch (error: any) {
console.error('保存草稿失败:', error)
// 草稿保存失败不显示错误消息,静默处理
}
}
/**
* 获取草稿列表
*/
const fetchDrafts = async () => {
try {
drafts.value = await diaryApi.getDrafts()
return drafts.value
} catch (error) {
console.error('获取草稿列表失败:', error)
throw error
}
}
/**
* 获取本地草稿
*/
const getLocalDraft = (): Partial<PublishDiaryRequest> | null => {
return storage.get(STORAGE_KEYS.DRAFT_DIARY)
}
/**
* 清除本地草稿
*/
const clearDraft = () => {
storage.remove(STORAGE_KEYS.DRAFT_DIARY)
}
/**
* 自动保存草稿
*/
const autoSaveDraft = (() => {
let timer: NodeJS.Timeout | null = null
return (data: Partial<PublishDiaryRequest>) => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
saveDraft(data)
}, 3000) // 3秒后自动保存
}
})()
/**
* 搜索日记
*/
const searchDiaries = async (keyword: string, filters: Partial<GetUserDiariesRequest> = {}) => {
const params: GetUserDiariesRequest = {
keyword,
...filters
}
currentPage.value = 1
await fetchUserDiaries(params)
}
/**
* 筛选日记
*/
const filterDiaries = async (filters: Partial<GetUserDiariesRequest>) => {
currentPage.value = 1
await fetchUserDiaries(filters)
}
/**
* 重置状态
*/
const resetState = () => {
diaries.value = []
currentDiary.value = null
drafts.value = []
total.value = 0
currentPage.value = 1
loading.value = false
publishing.value = false
}
/**
* 获取情绪统计
*/
const getEmotionStats = () => {
const stats: Record<string, number> = {}
diaries.value.forEach(diary => {
if (diary.emotion) {
stats[diary.emotion] = (stats[diary.emotion] || 0) + 1
}
})
return stats
}
/**
* 获取心情趋势
*/
const getMoodTrend = (days = 7) => {
const now = Date.now()
const dayMs = 24 * 60 * 60 * 1000
const trend = []
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now - i * dayMs)
const dayDiaries = diaries.value.filter(diary => {
const diaryDate = new Date(diary.createTime)
return diaryDate.toDateString() === date.toDateString()
})
const avgMood = dayDiaries.length > 0
? dayDiaries.reduce((sum, diary) => sum + diary.mood, 0) / dayDiaries.length
: 0
trend.push({
date: date.toISOString().split('T')[0],
mood: Math.round(avgMood * 10) / 10,
count: dayDiaries.length
})
}
return trend
}
return {
// 响应式数据
loading,
publishing,
diaries,
currentDiary,
drafts,
total,
currentPage,
pageSize,
// 计算属性
hasMore,
draftCount,
// 方法
fetchUserDiaries,
loadMoreDiaries,
refreshDiaries,
fetchDiaryDetail,
publishDiary,
updateDiary,
deleteDiary,
saveDraft,
fetchDrafts,
getLocalDraft,
clearDraft,
autoSaveDraft,
searchDiaries,
filterDiaries,
resetState,
getEmotionStats,
getMoodTrend
}
}
+283
View File
@@ -0,0 +1,283 @@
/**
* 用户功能组合式API
* 管理用户资料、头像上传、密码修改等
*/
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { userApi } from '@/api/user'
import { useAuthStore } from '@/stores/auth'
import { UPLOAD_CONFIG } from '@/config/constants'
import { validateEmail, validatePhone, validatePassword } from '@/utils/validation'
import type {
UserInfo,
UpdateUserProfileRequest,
ChangePasswordRequest,
UserGrowthStats
} from '@/types/api'
export function useUser() {
const authStore = useAuthStore()
// 响应式数据
const loading = ref(false)
const uploading = ref(false)
const userProfile = ref<UserInfo | null>(null)
const growthStats = ref<UserGrowthStats | null>(null)
// 计算属性
const currentUser = computed(() => authStore.user)
const isProfileComplete = computed(() => {
if (!userProfile.value) return false
const { email, phone, bio } = userProfile.value
return !!(email && phone && bio)
})
/**
* 获取用户资料
*/
const fetchUserProfile = async () => {
try {
loading.value = true
userProfile.value = await userApi.getProfile()
return userProfile.value
} catch (error) {
console.error('获取用户资料失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 更新用户资料
*/
const updateUserProfile = async (data: UpdateUserProfileRequest) => {
try {
// 验证数据
if (data.email && !validateEmail(data.email)) {
throw new Error('邮箱格式不正确')
}
if (data.phone && !validatePhone(data.phone)) {
throw new Error('手机号格式不正确')
}
const updatedUser = await userApi.updateProfile(data)
// 更新本地状态
userProfile.value = updatedUser
await authStore.updateUserInfo(updatedUser)
ElMessage.success('资料更新成功')
return updatedUser
} catch (error: any) {
ElMessage.error(error.message || '资料更新失败')
throw error
}
}
/**
* 修改密码
*/
const changePassword = async (data: ChangePasswordRequest) => {
try {
// 验证新密码
if (!validatePassword(data.newPassword)) {
throw new Error('新密码格式不正确,必须包含字母和数字,长度6-20位')
}
if (data.newPassword !== data.confirmPassword) {
throw new Error('两次输入的密码不一致')
}
await userApi.changePassword(data)
ElMessage.success('密码修改成功,请重新登录')
// 修改密码后需要重新登录
setTimeout(() => {
authStore.logout()
}, 2000)
} catch (error: any) {
ElMessage.error(error.message || '密码修改失败')
throw error
}
}
/**
* 上传头像
*/
const uploadAvatar = async (file: File) => {
try {
// 验证文件类型
if (!UPLOAD_CONFIG.AVATAR_ALLOWED_TYPES.includes(file.type)) {
throw new Error('只支持 JPG、PNG 格式的图片')
}
// 验证文件大小
if (file.size > UPLOAD_CONFIG.AVATAR_MAX_SIZE) {
throw new Error('图片大小不能超过 2MB')
}
uploading.value = true
const response = await userApi.uploadAvatar(file)
// 更新用户头像
if (userProfile.value) {
userProfile.value.avatar = response.url
}
await authStore.updateUserInfo({ avatar: response.url })
ElMessage.success('头像上传成功')
return response
} catch (error: any) {
ElMessage.error(error.message || '头像上传失败')
throw error
} finally {
uploading.value = false
}
}
/**
* 获取用户成长数据
*/
const fetchGrowthStats = async () => {
try {
loading.value = true
growthStats.value = await userApi.getGrowthStats()
return growthStats.value
} catch (error) {
console.error('获取成长数据失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 发送邮箱验证码
*/
const sendEmailVerificationCode = async (email: string, type: 'register' | 'reset_password' | 'verify_email' = 'verify_email') => {
try {
if (!validateEmail(email)) {
throw new Error('邮箱格式不正确')
}
await userApi.sendEmailCode({ email, type })
ElMessage.success('验证码已发送到您的邮箱')
} catch (error: any) {
ElMessage.error(error.message || '发送验证码失败')
throw error
}
}
/**
* 验证邮箱
*/
const verifyEmail = async (email: string, code: string) => {
try {
await userApi.verifyEmail({ email, code })
// 更新用户信息
if (userProfile.value) {
userProfile.value.email = email
}
ElMessage.success('邮箱验证成功')
} catch (error: any) {
ElMessage.error(error.message || '邮箱验证失败')
throw error
}
}
/**
* 发送手机验证码
*/
const sendPhoneVerificationCode = async (phone: string, type: 'register' | 'reset_password' | 'verify_phone' = 'verify_phone') => {
try {
if (!validatePhone(phone)) {
throw new Error('手机号格式不正确')
}
await userApi.sendPhoneCode({ phone, type })
ElMessage.success('验证码已发送到您的手机')
} catch (error: any) {
ElMessage.error(error.message || '发送验证码失败')
throw error
}
}
/**
* 验证手机号
*/
const verifyPhone = async (phone: string, code: string) => {
try {
await userApi.verifyPhone({ phone, code })
// 更新用户信息
if (userProfile.value) {
userProfile.value.phone = phone
}
ElMessage.success('手机号验证成功')
} catch (error: any) {
ElMessage.error(error.message || '手机号验证失败')
throw error
}
}
/**
* 检查头像文件
*/
const validateAvatarFile = (file: File): boolean => {
// 检查文件类型
if (!UPLOAD_CONFIG.AVATAR_ALLOWED_TYPES.includes(file.type)) {
ElMessage.error('只支持 JPG、PNG 格式的图片')
return false
}
// 检查文件大小
if (file.size > UPLOAD_CONFIG.AVATAR_MAX_SIZE) {
ElMessage.error('图片大小不能超过 2MB')
return false
}
return true
}
/**
* 重置状态
*/
const resetState = () => {
userProfile.value = null
growthStats.value = null
loading.value = false
uploading.value = false
}
return {
// 响应式数据
loading,
uploading,
userProfile,
growthStats,
// 计算属性
currentUser,
isProfileComplete,
// 方法
fetchUserProfile,
updateUserProfile,
changePassword,
uploadAvatar,
fetchGrowthStats,
sendEmailVerificationCode,
verifyEmail,
sendPhoneVerificationCode,
verifyPhone,
validateAvatarFile,
resetState
}
}
+214
View File
@@ -0,0 +1,214 @@
/**
* 应用常量定义
*/
// 存储键名
export const STORAGE_KEYS = {
TOKEN: 'emotion_museum_token',
REFRESH_TOKEN: 'emotion_museum_refresh_token',
USER_INFO: 'emotion_museum_user_info',
LANGUAGE: 'emotion_museum_language',
THEME: 'emotion_museum_theme',
CHAT_HISTORY: 'emotion_museum_chat_history',
DRAFT_DIARY: 'emotion_museum_draft_diary'
} as const
// API 路径
export const API_PATHS = {
// 认证相关
AUTH: {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
LOGOUT: '/auth/logout',
REFRESH_TOKEN: '/auth/refresh-token',
CAPTCHA: '/auth/captcha',
OAUTH_LOGIN: '/auth/oauth/login',
USER_INFO: '/auth/user/info'
},
// 用户相关
USER: {
PROFILE: '/user/profile',
GROWTH_STATS: '/user/growth-stats',
AVATAR_UPLOAD: '/user/avatar/upload',
PASSWORD: '/user/password',
EMAIL_VERIFY: '/user/email/verify',
EMAIL_SEND_CODE: '/user/email/send-code',
PHONE_VERIFY: '/user/phone/verify',
PHONE_SEND_CODE: '/user/phone/send-code'
},
// 对话相关
CONVERSATION: {
CREATE: '/conversation',
USER_LIST: '/conversation/user',
DELETE: '/conversation'
},
// 消息相关
MESSAGE: {
USER_PAGE: '/message/user/page',
USER_SEARCH: '/message/user/search',
USER_RECENT: '/message/user/recent',
DETAIL: '/message'
},
// 日记相关
DIARY: {
PUBLISH: '/diary-post/publish',
USER_PAGE: '/diary-post/user'
}
} as const
// WebSocket 路径
export const WS_PATHS = {
CHAT: '/ws/chat',
NOTIFICATIONS: '/ws/notifications'
} as const
// WebSocket 订阅路径
export const WS_SUBSCRIBE_PATHS = {
USER_MESSAGES: (userId: string) => `/user/${userId}/queue/messages`,
CHAT_ROOM: '/topic/chat',
NOTIFICATIONS: (userId: string) => `/user/${userId}/queue/notifications`
} as const
// WebSocket 发送路径
export const WS_SEND_PATHS = {
CHAT_SEND: '/app/chat.send',
TYPING: '/app/chat.typing'
} as const
// 分页配置
export const PAGINATION = {
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
DEFAULT_PAGE: 1
} as const
// 文件上传配置
export const UPLOAD_CONFIG = {
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
ALLOWED_FILE_TYPES: ['application/pdf', 'text/plain', 'application/msword'],
AVATAR_MAX_SIZE: 2 * 1024 * 1024, // 2MB
AVATAR_ALLOWED_TYPES: ['image/jpeg', 'image/png']
} as const
// 表单验证规则
export const VALIDATION_RULES = {
USERNAME: {
MIN_LENGTH: 3,
MAX_LENGTH: 20,
PATTERN: /^[a-zA-Z0-9_\u4e00-\u9fa5]+$/
},
PASSWORD: {
MIN_LENGTH: 6,
MAX_LENGTH: 20,
PATTERN: /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]+$/
},
EMAIL: {
PATTERN: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
PHONE: {
PATTERN: /^1[3-9]\d{9}$/
}
} as const
// 情绪类型
export const EMOTION_TYPES = {
HAPPY: 'happy',
SAD: 'sad',
ANGRY: 'angry',
CALM: 'calm',
EXCITED: 'excited',
ANXIOUS: 'anxious',
NEUTRAL: 'neutral'
} as const
// 情绪颜色映射
export const EMOTION_COLORS = {
[EMOTION_TYPES.HAPPY]: '#fbbf24',
[EMOTION_TYPES.SAD]: '#3b82f6',
[EMOTION_TYPES.ANGRY]: '#ef4444',
[EMOTION_TYPES.CALM]: '#10b981',
[EMOTION_TYPES.EXCITED]: '#f97316',
[EMOTION_TYPES.ANXIOUS]: '#8b5cf6',
[EMOTION_TYPES.NEUTRAL]: '#6b7280'
} as const
// 消息类型
export const MESSAGE_TYPES = {
TEXT: 'text',
IMAGE: 'image',
FILE: 'file',
EMOJI: 'emoji',
SYSTEM: 'system'
} as const
// 发送者类型
export const SENDER_TYPES = {
USER: 'USER',
AI: 'AI',
SYSTEM: 'SYSTEM'
} as const
// 路由名称
export const ROUTE_NAMES = {
HOME: 'Home',
LOGIN: 'Login',
REGISTER: 'Register',
CHAT: 'Chat',
CHAT_HISTORY: 'ChatHistory',
DIARY: 'Diary',
PERSONAL_DASHBOARD: 'PersonalDashboard',
PROFILE: 'Profile',
ANALYSIS: 'Analysis',
SETTINGS: 'Settings',
NOT_FOUND: 'NotFound'
} as const
// 主题配置
export const THEMES = {
LIGHT: 'light',
DARK: 'dark',
AUTO: 'auto'
} as const
// 语言配置
export const LANGUAGES = {
ZH_CN: 'zh-CN',
EN_US: 'en-US'
} as const
// 错误码
export const ERROR_CODES = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
NETWORK_ERROR: 'NETWORK_ERROR',
TIMEOUT: 'TIMEOUT'
} as const
// 成功状态码
export const SUCCESS_CODES = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204
} as const
// 缓存时间(毫秒)
export const CACHE_TIME = {
SHORT: 5 * 60 * 1000, // 5分钟
MEDIUM: 30 * 60 * 1000, // 30分钟
LONG: 2 * 60 * 60 * 1000, // 2小时
VERY_LONG: 24 * 60 * 60 * 1000 // 24小时
} as const
// 动画持续时间
export const ANIMATION_DURATION = {
FAST: 150,
NORMAL: 300,
SLOW: 500
} as const
+158
View File
@@ -0,0 +1,158 @@
/**
* 表情数据配置
*/
export interface EmojiItem {
char: string
code: string
name: string
category: string
keywords: string[]
}
export const EMOJI_DATA: EmojiItem[] = [
// 笑脸表情
{ char: '😀', code: 'grinning', name: '咧嘴笑', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
{ char: '😃', code: 'smiley', name: '笑脸', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
{ char: '😄', code: 'smile', name: '大笑', category: 'smileys', keywords: ['开心', '高兴', '大笑'] },
{ char: '😁', code: 'grin', name: '露齿笑', category: 'smileys', keywords: ['开心', '高兴', '笑'] },
{ char: '😆', code: 'laughing', name: '哈哈', category: 'smileys', keywords: ['开心', '高兴', '哈哈'] },
{ char: '😅', code: 'sweat_smile', name: '苦笑', category: 'smileys', keywords: ['苦笑', '尴尬'] },
{ char: '🤣', code: 'rofl', name: '笑哭', category: 'smileys', keywords: ['笑哭', '大笑'] },
{ char: '😂', code: 'joy', name: '喜极而泣', category: 'smileys', keywords: ['喜极而泣', '笑哭'] },
{ char: '🙂', code: 'slightly_smiling_face', name: '微笑', category: 'smileys', keywords: ['微笑', '开心'] },
{ char: '🙃', code: 'upside_down_face', name: '倒脸', category: 'smileys', keywords: ['倒脸', '搞怪'] },
{ char: '😉', code: 'wink', name: '眨眼', category: 'smileys', keywords: ['眨眼', '调皮'] },
{ char: '😊', code: 'blush', name: '害羞', category: 'smileys', keywords: ['害羞', '脸红'] },
{ char: '😇', code: 'innocent', name: '天使', category: 'smileys', keywords: ['天使', '纯真'] },
{ char: '🥰', code: 'smiling_face_with_hearts', name: '爱心眼', category: 'smileys', keywords: ['爱心', '喜欢'] },
{ char: '😍', code: 'heart_eyes', name: '花痴', category: 'smileys', keywords: ['花痴', '爱心眼'] },
{ char: '🤩', code: 'star_struck', name: '星星眼', category: 'smileys', keywords: ['星星眼', '崇拜'] },
{ char: '😘', code: 'kissing_heart', name: '飞吻', category: 'smileys', keywords: ['飞吻', '亲吻'] },
{ char: '😗', code: 'kissing', name: '亲吻', category: 'smileys', keywords: ['亲吻', '吻'] },
{ char: '☺️', code: 'relaxed', name: '满足', category: 'smileys', keywords: ['满足', '开心'] },
{ char: '😚', code: 'kissing_closed_eyes', name: '闭眼亲吻', category: 'smileys', keywords: ['亲吻', '闭眼'] },
// 难过表情
{ char: '😢', code: 'cry', name: '哭泣', category: 'smileys', keywords: ['哭泣', '难过', '伤心'] },
{ char: '😭', code: 'sob', name: '大哭', category: 'smileys', keywords: ['大哭', '难过', '伤心'] },
{ char: '😤', code: 'triumph', name: '生气', category: 'smileys', keywords: ['生气', '愤怒'] },
{ char: '😠', code: 'angry', name: '愤怒', category: 'smileys', keywords: ['愤怒', '生气'] },
{ char: '😡', code: 'rage', name: '暴怒', category: 'smileys', keywords: ['暴怒', '愤怒'] },
{ char: '🤬', code: 'swearing', name: '骂人', category: 'smileys', keywords: ['骂人', '愤怒'] },
{ char: '😱', code: 'scream', name: '尖叫', category: 'smileys', keywords: ['尖叫', '惊恐'] },
{ char: '😨', code: 'fearful', name: '恐惧', category: 'smileys', keywords: ['恐惧', '害怕'] },
{ char: '😰', code: 'cold_sweat', name: '冷汗', category: 'smileys', keywords: ['冷汗', '紧张'] },
{ char: '😥', code: 'disappointed_relieved', name: '失望', category: 'smileys', keywords: ['失望', '难过'] },
// 其他表情
{ char: '😴', code: 'sleeping', name: '睡觉', category: 'smileys', keywords: ['睡觉', '困'] },
{ char: '🤤', code: 'drooling', name: '流口水', category: 'smileys', keywords: ['流口水', '馋'] },
{ char: '😪', code: 'sleepy', name: '困倦', category: 'smileys', keywords: ['困倦', '累'] },
{ char: '🤔', code: 'thinking', name: '思考', category: 'smileys', keywords: ['思考', '想'] },
{ char: '🤫', code: 'shushing', name: '嘘', category: 'smileys', keywords: ['嘘', '安静'] },
{ char: '🤭', code: 'hand_over_mouth', name: '捂嘴', category: 'smileys', keywords: ['捂嘴', '惊讶'] },
{ char: '🙄', code: 'eye_roll', name: '翻白眼', category: 'smileys', keywords: ['翻白眼', '无语'] },
{ char: '😏', code: 'smirk', name: '得意', category: 'smileys', keywords: ['得意', '坏笑'] },
{ char: '😒', code: 'unamused', name: '无趣', category: 'smileys', keywords: ['无趣', '无聊'] },
{ char: '🙁', code: 'frowning', name: '皱眉', category: 'smileys', keywords: ['皱眉', '不开心'] },
// 人物手势
{ char: '👋', code: 'wave', name: '挥手', category: 'people', keywords: ['挥手', '再见', '你好'] },
{ char: '🤚', code: 'raised_back_of_hand', name: '举手', category: 'people', keywords: ['举手', '停'] },
{ char: '🖐️', code: 'raised_hand_with_fingers_splayed', name: '张开手', category: 'people', keywords: ['张开手', '五'] },
{ char: '✋', code: 'raised_hand', name: '举手', category: 'people', keywords: ['举手', '停'] },
{ char: '🖖', code: 'vulcan_salute', name: '瓦肯礼', category: 'people', keywords: ['瓦肯礼', '问候'] },
{ char: '👌', code: 'ok_hand', name: 'OK', category: 'people', keywords: ['OK', '好的'] },
{ char: '🤏', code: 'pinching_hand', name: '捏', category: 'people', keywords: ['捏', '一点点'] },
{ char: '✌️', code: 'v', name: '胜利', category: 'people', keywords: ['胜利', 'V', '耶'] },
{ char: '🤞', code: 'crossed_fingers', name: '祈祷', category: 'people', keywords: ['祈祷', '希望'] },
{ char: '🤟', code: 'love_you_gesture', name: '爱你', category: 'people', keywords: ['爱你', '手势'] },
{ char: '🤘', code: 'metal', name: '摇滚', category: 'people', keywords: ['摇滚', '酷'] },
{ char: '🤙', code: 'call_me_hand', name: '打电话', category: 'people', keywords: ['打电话', '联系'] },
{ char: '👈', code: 'point_left', name: '向左指', category: 'people', keywords: ['向左', '指'] },
{ char: '👉', code: 'point_right', name: '向右指', category: 'people', keywords: ['向右', '指'] },
{ char: '👆', code: 'point_up_2', name: '向上指', category: 'people', keywords: ['向上', '指'] },
{ char: '🖕', code: 'middle_finger', name: '中指', category: 'people', keywords: ['中指', '鄙视'] },
{ char: '👇', code: 'point_down', name: '向下指', category: 'people', keywords: ['向下', '指'] },
{ char: '☝️', code: 'point_up', name: '食指向上', category: 'people', keywords: ['食指', '向上'] },
{ char: '👍', code: 'thumbsup', name: '赞', category: 'people', keywords: ['赞', '好', '棒'] },
{ char: '👎', code: 'thumbsdown', name: '踩', category: 'people', keywords: ['踩', '不好', '差'] },
// 自然
{ char: '🌱', code: 'seedling', name: '幼苗', category: 'nature', keywords: ['幼苗', '植物', '成长'] },
{ char: '🌿', code: 'herb', name: '草本', category: 'nature', keywords: ['草本', '植物'] },
{ char: '🍀', code: 'four_leaf_clover', name: '四叶草', category: 'nature', keywords: ['四叶草', '幸运'] },
{ char: '🌸', code: 'cherry_blossom', name: '樱花', category: 'nature', keywords: ['樱花', '花'] },
{ char: '🌺', code: 'hibiscus', name: '芙蓉花', category: 'nature', keywords: ['芙蓉花', '花'] },
{ char: '🌻', code: 'sunflower', name: '向日葵', category: 'nature', keywords: ['向日葵', '花'] },
{ char: '🌹', code: 'rose', name: '玫瑰', category: 'nature', keywords: ['玫瑰', '花', '爱情'] },
{ char: '🌷', code: 'tulip', name: '郁金香', category: 'nature', keywords: ['郁金香', '花'] },
{ char: '🌲', code: 'evergreen_tree', name: '常青树', category: 'nature', keywords: ['常青树', '树'] },
{ char: '🌳', code: 'deciduous_tree', name: '落叶树', category: 'nature', keywords: ['落叶树', '树'] },
// 食物
{ char: '🍎', code: 'apple', name: '苹果', category: 'food', keywords: ['苹果', '水果'] },
{ char: '🍊', code: 'tangerine', name: '橘子', category: 'food', keywords: ['橘子', '水果'] },
{ char: '🍋', code: 'lemon', name: '柠檬', category: 'food', keywords: ['柠檬', '水果'] },
{ char: '🍌', code: 'banana', name: '香蕉', category: 'food', keywords: ['香蕉', '水果'] },
{ char: '🍉', code: 'watermelon', name: '西瓜', category: 'food', keywords: ['西瓜', '水果'] },
{ char: '🍇', code: 'grapes', name: '葡萄', category: 'food', keywords: ['葡萄', '水果'] },
{ char: '🍓', code: 'strawberry', name: '草莓', category: 'food', keywords: ['草莓', '水果'] },
{ char: '🍑', code: 'cherries', name: '樱桃', category: 'food', keywords: ['樱桃', '水果'] },
{ char: '🍒', code: 'cherry', name: '樱桃', category: 'food', keywords: ['樱桃', '水果'] },
{ char: '🥝', code: 'kiwi_fruit', name: '猕猴桃', category: 'food', keywords: ['猕猴桃', '水果'] },
// 活动
{ char: '⚽', code: 'soccer', name: '足球', category: 'activity', keywords: ['足球', '运动'] },
{ char: '🏀', code: 'basketball', name: '篮球', category: 'activity', keywords: ['篮球', '运动'] },
{ char: '🏈', code: 'football', name: '橄榄球', category: 'activity', keywords: ['橄榄球', '运动'] },
{ char: '⚾', code: 'baseball', name: '棒球', category: 'activity', keywords: ['棒球', '运动'] },
{ char: '🎾', code: 'tennis', name: '网球', category: 'activity', keywords: ['网球', '运动'] },
{ char: '🏐', code: 'volleyball', name: '排球', category: 'activity', keywords: ['排球', '运动'] },
{ char: '🏓', code: 'ping_pong', name: '乒乓球', category: 'activity', keywords: ['乒乓球', '运动'] },
{ char: '🏸', code: 'badminton', name: '羽毛球', category: 'activity', keywords: ['羽毛球', '运动'] },
{ char: '🥅', code: 'goal_net', name: '球门', category: 'activity', keywords: ['球门', '运动'] },
{ char: '🎯', code: 'dart', name: '飞镖', category: 'activity', keywords: ['飞镖', '游戏'] },
// 符号
{ char: '❤️', code: 'heart', name: '红心', category: 'symbols', keywords: ['红心', '爱', '喜欢'] },
{ char: '🧡', code: 'orange_heart', name: '橙心', category: 'symbols', keywords: ['橙心', '爱'] },
{ char: '💛', code: 'yellow_heart', name: '黄心', category: 'symbols', keywords: ['黄心', '爱'] },
{ char: '💚', code: 'green_heart', name: '绿心', category: 'symbols', keywords: ['绿心', '爱'] },
{ char: '💙', code: 'blue_heart', name: '蓝心', category: 'symbols', keywords: ['蓝心', '爱'] },
{ char: '💜', code: 'purple_heart', name: '紫心', category: 'symbols', keywords: ['紫心', '爱'] },
{ char: '🖤', code: 'black_heart', name: '黑心', category: 'symbols', keywords: ['黑心', '爱'] },
{ char: '🤍', code: 'white_heart', name: '白心', category: 'symbols', keywords: ['白心', '爱'] },
{ char: '🤎', code: 'brown_heart', name: '棕心', category: 'symbols', keywords: ['棕心', '爱'] },
{ char: '💔', code: 'broken_heart', name: '心碎', category: 'symbols', keywords: ['心碎', '伤心'] },
{ char: '❣️', code: 'heavy_heart_exclamation', name: '心叹号', category: 'symbols', keywords: ['心叹号', '爱'] },
{ char: '💕', code: 'two_hearts', name: '双心', category: 'symbols', keywords: ['双心', '爱'] },
{ char: '💞', code: 'revolving_hearts', name: '旋转心', category: 'symbols', keywords: ['旋转心', '爱'] },
{ char: '💓', code: 'heartbeat', name: '心跳', category: 'symbols', keywords: ['心跳', '爱'] },
{ char: '💗', code: 'heartpulse', name: '心脉', category: 'symbols', keywords: ['心脉', '爱'] },
{ char: '💖', code: 'sparkling_heart', name: '闪亮心', category: 'symbols', keywords: ['闪亮心', '爱'] },
{ char: '💘', code: 'cupid', name: '丘比特', category: 'symbols', keywords: ['丘比特', '爱'] },
{ char: '💝', code: 'gift_heart', name: '礼物心', category: 'symbols', keywords: ['礼物心', '爱'] },
{ char: '💟', code: 'heart_decoration', name: '心装饰', category: 'symbols', keywords: ['心装饰', '爱'] }
]
// 根据分类获取表情
export const getEmojisByCategory = (category: string): EmojiItem[] => {
return EMOJI_DATA.filter(emoji => emoji.category === category)
}
// 搜索表情
export const searchEmojis = (keyword: string): EmojiItem[] => {
const lowerKeyword = keyword.toLowerCase()
return EMOJI_DATA.filter(emoji =>
emoji.name.toLowerCase().includes(lowerKeyword) ||
emoji.keywords.some(k => k.toLowerCase().includes(lowerKeyword))
)
}
// 获取随机表情
export const getRandomEmojis = (count: number = 10): EmojiItem[] => {
const shuffled = [...EMOJI_DATA].sort(() => 0.5 - Math.random())
return shuffled.slice(0, count)
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 环境配置管理
* 支持 local/dev/test/prod 四种环境
*/
export interface EnvConfig {
name: string
apiBaseUrl: string
wsBaseUrl: string
uploadUrl: string
debug: boolean
mock: boolean
appTitle: string
appVersion: string
}
// 环境配置映射
const envConfigs: Record<string, EnvConfig> = {
local: {
name: '本地环境',
apiBaseUrl: 'http://localhost:19089/api',
wsBaseUrl: 'ws://localhost:19089',
uploadUrl: 'http://localhost:19089/api/upload',
debug: true,
mock: false,
appTitle: '情绪博物馆 - 本地',
appVersion: '1.0.0'
},
dev: {
name: '开发环境',
apiBaseUrl: 'https://dev-api.emotion-museum.com/api',
wsBaseUrl: 'wss://dev-api.emotion-museum.com',
uploadUrl: 'https://dev-api.emotion-museum.com/api/upload',
debug: true,
mock: false,
appTitle: '情绪博物馆 - 开发',
appVersion: '1.0.0'
},
test: {
name: '测试环境',
apiBaseUrl: 'https://test-api.emotion-museum.com/api',
wsBaseUrl: 'wss://test-api.emotion-museum.com',
uploadUrl: 'https://test-api.emotion-museum.com/api/upload',
debug: false,
mock: false,
appTitle: '情绪博物馆 - 测试',
appVersion: '1.0.0'
},
prod: {
name: '生产环境',
apiBaseUrl: 'https://api.emotion-museum.com/api',
wsBaseUrl: 'wss://api.emotion-museum.com',
uploadUrl: 'https://api.emotion-museum.com/api/upload',
debug: false,
mock: false,
appTitle: '情绪博物馆',
appVersion: '1.0.0'
}
}
// 获取当前环境
function getCurrentEnv(): string {
// 优先使用环境变量
const viteEnv = import.meta.env.VITE_APP_ENV
if (viteEnv && envConfigs[viteEnv]) {
return viteEnv
}
// 根据域名判断环境
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'local'
} else if (hostname.includes('dev')) {
return 'dev'
} else if (hostname.includes('test')) {
return 'test'
} else {
return 'prod'
}
}
// 当前环境配置
export const currentEnv = getCurrentEnv()
export const envConfig = envConfigs[currentEnv]
// 环境变量覆盖
if (import.meta.env.VITE_API_BASE_URL) {
envConfig.apiBaseUrl = import.meta.env.VITE_API_BASE_URL
}
if (import.meta.env.VITE_WS_BASE_URL) {
envConfig.wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
}
if (import.meta.env.VITE_UPLOAD_URL) {
envConfig.uploadUrl = import.meta.env.VITE_UPLOAD_URL
}
if (import.meta.env.VITE_DEBUG) {
envConfig.debug = import.meta.env.VITE_DEBUG === 'true'
}
if (import.meta.env.VITE_MOCK) {
envConfig.mock = import.meta.env.VITE_MOCK === 'true'
}
if (import.meta.env.VITE_APP_TITLE) {
envConfig.appTitle = import.meta.env.VITE_APP_TITLE
}
if (import.meta.env.VITE_APP_VERSION) {
envConfig.appVersion = import.meta.env.VITE_APP_VERSION
}
// 开发环境下打印配置信息
if (envConfig.debug) {
console.log('🔧 当前环境配置:', {
环境: envConfig.name,
API地址: envConfig.apiBaseUrl,
WebSocket地址: envConfig.wsBaseUrl,
上传地址: envConfig.uploadUrl,
调试模式: envConfig.debug,
Mock模式: envConfig.mock
})
}
// 导出配置验证函数
export function validateConfig(): boolean {
const required = ['apiBaseUrl', 'wsBaseUrl', 'uploadUrl']
for (const key of required) {
if (!envConfig[key as keyof EnvConfig]) {
console.error(`❌ 环境配置缺失: ${key}`)
return false
}
}
return true
}
// 导出所有环境配置(用于调试)
export { envConfigs }
+26
View File
@@ -0,0 +1,26 @@
/**
* 国际化配置
*/
import { createI18n } from 'vue-i18n'
import { getLanguage } from '@/utils/storage'
import { LANGUAGES } from '@/config/constants'
// 导入语言文件
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
const messages = {
[LANGUAGES.ZH_CN]: zhCN,
[LANGUAGES.EN_US]: enUS
}
export const i18n = createI18n({
legacy: false,
locale: getLanguage() || LANGUAGES.ZH_CN,
fallbackLocale: LANGUAGES.ZH_CN,
messages,
globalInjection: true
})
export default i18n
+73
View File
@@ -0,0 +1,73 @@
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"search": "Search",
"reset": "Reset",
"submit": "Submit",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info"
},
"auth": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"email": "Email",
"phone": "Phone",
"captcha": "Captcha",
"rememberMe": "Remember Me",
"forgotPassword": "Forgot Password?",
"loginSuccess": "Login successful",
"registerSuccess": "Registration successful",
"logoutSuccess": "Logout successful"
},
"nav": {
"home": "Home",
"chat": "AI Chat",
"diary": "Emotion Diary",
"dashboard": "Dashboard",
"analysis": "Analysis",
"profile": "Profile",
"settings": "Settings"
},
"chat": {
"sendMessage": "Send Message",
"typing": "Typing...",
"offline": "Offline",
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected",
"reconnecting": "Reconnecting..."
},
"diary": {
"title": "Title",
"content": "Content",
"emotion": "Emotion",
"mood": "Mood",
"weather": "Weather",
"location": "Location",
"tags": "Tags",
"publish": "Publish",
"draft": "Draft",
"public": "Public",
"private": "Private"
},
"error": {
"404": "Page Not Found",
"403": "Access Denied",
"500": "Server Error",
"network": "Network Error",
"timeout": "Request Timeout",
"unknown": "Unknown Error"
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"common": {
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"search": "搜索",
"reset": "重置",
"submit": "提交",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"warning": "警告",
"info": "信息"
},
"auth": {
"login": "登录",
"register": "注册",
"logout": "退出登录",
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"email": "邮箱",
"phone": "手机号",
"captcha": "验证码",
"rememberMe": "记住我",
"forgotPassword": "忘记密码?",
"loginSuccess": "登录成功",
"registerSuccess": "注册成功",
"logoutSuccess": "退出登录成功"
},
"nav": {
"home": "首页",
"chat": "AI对话",
"diary": "情绪日记",
"dashboard": "个人仪表盘",
"analysis": "情绪分析",
"profile": "个人资料",
"settings": "设置"
},
"chat": {
"sendMessage": "发送消息",
"typing": "正在输入...",
"offline": "离线",
"connected": "已连接",
"connecting": "连接中...",
"disconnected": "已断开",
"reconnecting": "重连中..."
},
"diary": {
"title": "标题",
"content": "内容",
"emotion": "情绪",
"mood": "心情指数",
"weather": "天气",
"location": "位置",
"tags": "标签",
"publish": "发布",
"draft": "草稿",
"public": "公开",
"private": "私密"
},
"error": {
"404": "页面不存在",
"403": "权限不足",
"500": "服务器错误",
"network": "网络错误",
"timeout": "请求超时",
"unknown": "未知错误"
}
}
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="auth-layout min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<!-- 背景装饰 -->
<div class="absolute inset-0 overflow-hidden">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full opacity-20 animate-pulse-slow"></div>
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-gradient-to-tr from-pink-400 to-yellow-500 rounded-full opacity-20 animate-bounce-gentle"></div>
</div>
<!-- 主要内容 -->
<div class="relative w-full max-w-md">
<!-- Logo和标题 -->
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg">
<el-icon size="32" class="text-white">
<Sunny />
</el-icon>
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪博物馆</h1>
<p class="text-gray-600">记录情绪分享心情的温暖空间</p>
</div>
<!-- 认证表单容器 -->
<div class="bg-white rounded-2xl shadow-xl p-8 backdrop-blur-sm bg-opacity-95">
<router-view />
</div>
<!-- 底部链接 -->
<div class="text-center mt-6 space-y-2">
<div class="flex justify-center space-x-4 text-sm text-gray-600">
<a href="#" class="hover:text-blue-600 transition-colors">帮助中心</a>
<span>·</span>
<a href="#" class="hover:text-blue-600 transition-colors">隐私政策</a>
<span>·</span>
<a href="#" class="hover:text-blue-600 transition-colors">服务条款</a>
</div>
<p class="text-xs text-gray-500">
© 2024 情绪博物馆. All rights reserved.
</p>
</div>
</div>
<!-- 主题切换按钮 -->
<div class="absolute top-4 right-4">
<el-button circle @click="toggleTheme" class="bg-white bg-opacity-80 backdrop-blur-sm">
<el-icon>
<Sunny v-if="!isDarkTheme" />
<Moon v-else />
</el-icon>
</el-button>
</div>
<!-- 语言切换按钮 -->
<div class="absolute top-4 left-4">
<el-dropdown @command="handleLanguageChange">
<el-button circle class="bg-white bg-opacity-80 backdrop-blur-sm">
<el-icon><Globe /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
<el-dropdown-item command="en-US">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Sunny, Moon, Globe } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
// 计算属性
const isDarkTheme = computed(() => appStore.isDarkTheme)
// 方法
const toggleTheme = () => {
appStore.toggleTheme()
}
const handleLanguageChange = (language: string) => {
appStore.setLanguage(language)
}
</script>
<style scoped>
.auth-layout {
background-image:
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.3) 0%, transparent 50%);
}
@keyframes pulse-slow {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.3;
}
}
@keyframes bounce-gentle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-bounce-gentle {
animation: bounce-gentle 6s ease-in-out infinite;
}
</style>
+376
View File
@@ -0,0 +1,376 @@
<template>
<div class="chat-layout h-screen flex bg-gray-50">
<!-- 侧边栏 -->
<aside
class="sidebar bg-white border-r border-gray-200 flex flex-col transition-all duration-300"
:class="sidebarCollapsed ? 'w-16' : 'w-80'"
>
<!-- 侧边栏头部 -->
<header class="sidebar-header p-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div v-if="!sidebarCollapsed" class="flex items-center space-x-3">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<el-icon size="16" class="text-white">
<ChatDotRound />
</el-icon>
</div>
<div>
<h1 class="text-lg font-semibold text-gray-900">聊天</h1>
</div>
</div>
<el-button
circle
size="small"
@click="toggleSidebar"
class="flex-shrink-0"
>
<el-icon>
<Expand v-if="sidebarCollapsed" />
<Fold v-else />
</el-icon>
</el-button>
</div>
</header>
<!-- 会话列表 -->
<div class="conversation-list flex-1 overflow-hidden">
<div v-if="!sidebarCollapsed" class="p-4">
<!-- 新建对话按钮 -->
<el-button
type="primary"
class="w-full mb-4"
@click="createNewConversation"
>
<el-icon class="mr-2"><Plus /></el-icon>
新建对话
</el-button>
<!-- 搜索框 -->
<el-input
v-model="searchKeyword"
placeholder="搜索对话..."
clearable
class="mb-4"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 对话列表 -->
<div class="conversation-items overflow-y-auto flex-1">
<div v-if="filteredConversations.length === 0" class="text-center py-8">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<el-icon size="24" class="text-gray-400">
<ChatDotRound />
</el-icon>
</div>
<p class="text-gray-500 text-sm">暂无对话记录</p>
</div>
<div
v-for="conversation in filteredConversations"
:key="conversation.id"
class="conversation-item"
:class="{ 'active': currentConversationId === conversation.id }"
@click="selectConversation(conversation.id)"
>
<div v-if="sidebarCollapsed" class="p-3 flex justify-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<el-icon size="16" class="text-blue-600">
<ChatDotRound />
</el-icon>
</div>
</div>
<div v-else class="p-4">
<div class="flex items-start space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0">
<el-icon size="16" class="text-white">
<ChatDotRound />
</el-icon>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-medium text-gray-900 truncate">
{{ conversation.title || 'AI对话' }}
</h3>
<span class="text-xs text-gray-500">
{{ formatTime(conversation.updateTime) }}
</span>
</div>
<p class="text-xs text-gray-600 truncate">
{{ conversation.lastMessage || '开始新的对话...' }}
</p>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-500">
{{ conversation.messageCount }} 条消息
</span>
<el-dropdown @command="handleConversationAction">
<el-button circle size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="`rename_${conversation.id}`">
重命名
</el-dropdown-item>
<el-dropdown-item :command="`archive_${conversation.id}`">
归档
</el-dropdown-item>
<el-dropdown-item
:command="`delete_${conversation.id}`"
divided
>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 侧边栏底部 -->
<footer v-if="!sidebarCollapsed" class="sidebar-footer p-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<el-avatar :src="userAvatar" :size="32">
<el-icon><User /></el-icon>
</el-avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ userNickname }}
</p>
<p class="text-xs text-gray-500">
<span :class="connectionStatusClass">{{ connectionStatusText }}</span>
</p>
</div>
<el-dropdown @command="handleUserAction">
<el-button circle size="small" text>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="settings">设置</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</footer>
</aside>
<!-- 主要内容区域 -->
<main class="main-content flex-1 flex flex-col">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatDotRound,
Expand,
Fold,
Plus,
Search,
MoreFilled,
User,
Setting
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useChat } from '@/composables/useChat'
import { formatRelativeTime } from '@/utils/format'
// 状态管理
const authStore = useAuthStore()
const appStore = useAppStore()
const router = useRouter()
// 聊天功能
const { connectionState } = useChat()
// 响应式数据
const searchKeyword = ref('')
const currentConversationId = ref('')
const conversations = ref([
{
id: '1',
title: 'AI助手对话',
lastMessage: '你好,有什么可以帮助你的吗?',
updateTime: Date.now() - 1000 * 60 * 5,
messageCount: 12
},
{
id: '2',
title: '情绪咨询',
lastMessage: '今天感觉怎么样?',
updateTime: Date.now() - 1000 * 60 * 60,
messageCount: 8
}
])
// 计算属性
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const userAvatar = computed(() => authStore.avatar)
const userNickname = computed(() => authStore.nickname)
const connectionStatusText = computed(() => {
switch (connectionState.value) {
case 'CONNECTED':
return '在线'
case 'CONNECTING':
return '连接中...'
case 'DISCONNECTED':
return '离线'
case 'ERROR':
return '连接错误'
default:
return '未知状态'
}
})
const connectionStatusClass = computed(() => {
switch (connectionState.value) {
case 'CONNECTED':
return 'text-green-500'
case 'CONNECTING':
return 'text-yellow-500'
case 'DISCONNECTED':
return 'text-gray-500'
case 'ERROR':
return 'text-red-500'
default:
return 'text-gray-500'
}
})
const filteredConversations = computed(() => {
if (!searchKeyword.value) return conversations.value
return conversations.value.filter(conv =>
conv.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
conv.lastMessage?.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
// 方法
const toggleSidebar = () => {
appStore.toggleSidebar()
}
const createNewConversation = () => {
ElMessage.info('新建对话功能开发中...')
}
const selectConversation = (conversationId: string) => {
currentConversationId.value = conversationId
// 这里可以加载对话历史
ElMessage.success(`切换到对话: ${conversationId}`)
}
const handleSearch = () => {
// 搜索逻辑已在计算属性中实现
}
const handleConversationAction = (command: string) => {
const [action, conversationId] = command.split('_')
switch (action) {
case 'rename':
ElMessage.info('重命名功能开发中...')
break
case 'archive':
ElMessage.info('归档功能开发中...')
break
case 'delete':
ElMessageBox.confirm(
'确定要删除这个对话吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
ElMessage.success('对话已删除')
})
break
}
}
const handleUserAction = async (command: string) => {
switch (command) {
case 'profile':
await router.push('/app/profile')
break
case 'settings':
await router.push('/app/settings')
break
case 'logout':
ElMessageBox.confirm(
'确定要退出登录吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
authStore.logout()
})
break
}
}
const formatTime = (timestamp: number) => {
return formatRelativeTime(timestamp)
}
// 生命周期
onMounted(() => {
// 选择第一个对话
if (conversations.value.length > 0) {
currentConversationId.value = conversations.value[0].id
}
})
</script>
<style scoped>
.conversation-item {
@apply cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100;
}
.conversation-item.active {
@apply bg-blue-50 border-blue-200;
}
.conversation-item:last-child {
@apply border-b-0;
}
.sidebar {
min-width: 4rem;
max-width: 20rem;
}
.conversation-items {
height: calc(100vh - 200px);
}
</style>
+307
View File
@@ -0,0 +1,307 @@
<template>
<div class="default-layout min-h-screen bg-gray-50">
<!-- 顶部导航栏 -->
<header class="header bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 左侧Logo和导航 -->
<div class="flex items-center space-x-8">
<!-- Logo -->
<router-link to="/home" class="flex items-center space-x-2">
<img src="/logo.png" alt="情绪博物馆" class="h-8 w-8" />
<span class="text-xl font-bold text-gray-900">情绪博物馆</span>
</router-link>
<!-- 主导航 -->
<nav class="hidden md:flex space-x-6">
<router-link
v-for="item in mainNavItems"
:key="item.path"
:to="item.path"
class="nav-link"
:class="{ 'active': isActiveRoute(item.path) }"
>
<el-icon class="mr-1">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</router-link>
</nav>
</div>
<!-- 右侧用户操作 -->
<div class="flex items-center space-x-4">
<!-- 搜索 -->
<el-input
v-model="searchKeyword"
placeholder="搜索..."
class="w-64 hidden lg:block"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<!-- 通知 -->
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-button circle @click="showNotifications">
<el-icon><Bell /></el-icon>
</el-button>
</el-badge>
<!-- 主题切换 -->
<el-button circle @click="toggleTheme">
<el-icon>
<Sunny v-if="!isDarkTheme" />
<Moon v-else />
</el-icon>
</el-button>
<!-- 用户菜单 -->
<el-dropdown @command="handleUserCommand">
<div class="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 rounded-lg p-2">
<el-avatar :src="userAvatar" :size="32">
<el-icon><User /></el-icon>
</el-avatar>
<span class="hidden md:block text-sm font-medium text-gray-700">
{{ userNickname }}
</span>
<el-icon class="text-gray-400"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
个人资料
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</header>
<!-- 主要内容区域 -->
<main class="main-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 面包屑导航 -->
<el-breadcrumb v-if="showBreadcrumb" class="mb-6" separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbItems"
:key="item.path"
:to="item.path"
>
<el-icon v-if="item.icon" class="mr-1">
<component :is="item.icon" />
</el-icon>
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
<!-- 页面内容 -->
<div class="content-wrapper">
<router-view />
</div>
</div>
</main>
<!-- 底部 -->
<footer v-if="showFooter" class="footer bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-4">关于我们</h3>
<p class="text-sm text-gray-600">
情绪博物馆致力于为用户提供情绪记录和心理健康服务
</p>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-4">功能</h3>
<ul class="space-y-2 text-sm text-gray-600">
<li><router-link to="/chat" class="hover:text-primary-600">AI对话</router-link></li>
<li><router-link to="/app/diary" class="hover:text-primary-600">情绪日记</router-link></li>
<li><router-link to="/app/analysis" class="hover:text-primary-600">情绪分析</router-link></li>
</ul>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-4">支持</h3>
<ul class="space-y-2 text-sm text-gray-600">
<li><a href="#" class="hover:text-primary-600">帮助中心</a></li>
<li><a href="#" class="hover:text-primary-600">联系我们</a></li>
<li><a href="#" class="hover:text-primary-600">隐私政策</a></li>
</ul>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-4">关注我们</h3>
<div class="flex space-x-4">
<a href="#" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">微信</span>
<el-icon size="20"><ChatDotRound /></el-icon>
</a>
<a href="#" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">微博</span>
<el-icon size="20"><Share /></el-icon>
</a>
</div>
</div>
</div>
<div class="mt-8 pt-8 border-t border-gray-200">
<p class="text-center text-sm text-gray-500">
© 2024 情绪博物馆. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Bell,
Sunny,
Moon,
User,
ArrowDown,
Setting,
SwitchButton,
House,
ChatDotRound,
EditPen,
DataBoard,
TrendCharts,
Share
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { menuConfig } from '@/router'
// 状态管理
const authStore = useAuthStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
// 响应式数据
const searchKeyword = ref('')
const unreadCount = ref(0)
// 计算属性
const userAvatar = computed(() => authStore.avatar)
const userNickname = computed(() => authStore.nickname)
const isDarkTheme = computed(() => appStore.isDarkTheme)
const showBreadcrumb = computed(() => appStore.pageSettings.showBreadcrumb)
const showFooter = computed(() => appStore.pageSettings.showFooter)
// 主导航项目
const mainNavItems = computed(() => {
return menuConfig.filter(item =>
!item.requireAuth || authStore.isLoggedIn
).filter(item => !item.children)
})
// 面包屑导航
const breadcrumbItems = computed(() => {
const items = []
const matched = route.matched.filter(item => item.meta && item.meta.title)
// 添加首页
items.push({ title: '首页', path: '/home', icon: 'House' })
// 添加匹配的路由
matched.forEach(match => {
if (match.path !== '/home') {
items.push({
title: match.meta.title,
path: match.path,
icon: match.meta.icon
})
}
})
return items
})
// 方法
const isActiveRoute = (path: string) => {
return route.path.startsWith(path)
}
const handleSearch = () => {
if (searchKeyword.value.trim()) {
router.push({
path: '/search',
query: { q: searchKeyword.value.trim() }
})
}
}
const showNotifications = () => {
ElMessage.info('通知功能开发中...')
}
const toggleTheme = () => {
appStore.toggleTheme()
}
const handleUserCommand = async (command: string) => {
switch (command) {
case 'profile':
await router.push('/app/profile')
break
case 'settings':
await router.push('/app/settings')
break
case 'logout':
ElMessageBox.confirm(
'确定要退出登录吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
authStore.logout()
})
break
}
}
// 监听路由变化,更新页面标题
watch(() => route.meta.title, (title) => {
if (title) {
document.title = `${title} - ${appStore.title}`
}
}, { immediate: true })
</script>
<style scoped>
.nav-link {
@apply flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:text-primary-600 hover:bg-gray-50 transition-colors;
}
.nav-link.active {
@apply text-primary-600 bg-primary-50;
}
.content-wrapper {
min-height: calc(100vh - 200px);
}
</style>
+50
View File
@@ -0,0 +1,50 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
// 样式导入
import 'element-plus/dist/index.css'
import './assets/styles/main.css'
// 组件导入
import App from './App.vue'
import router from './router'
// import { i18n } from './i18n'
// 插件导入
// import { registerGlobalComponents } from './plugins/global-components'
// import { setupErrorHandler } from './plugins/error-handler'
// import { setupProgressBar } from './plugins/progress-bar'
// 创建应用实例
const app = createApp(App)
// 创建状态管理实例
const pinia = createPinia()
// 注册插件
app.use(pinia)
app.use(router)
// app.use(i18n)
// 设置路由守卫
// import { setupRouterGuards } from './router/guards'
// setupRouterGuards(router, pinia)
// 注册全局组件
// registerGlobalComponents(app)
// 设置错误处理
// setupErrorHandler(app)
// 设置进度条
// setupProgressBar(router)
// 挂载应用
app.mount('#app')
// 开发环境下的调试信息
if (import.meta.env.DEV) {
console.log('🚀 情绪博物馆 Web 应用启动成功')
console.log('📦 Vue版本:', app.version)
console.log('🔧 环境:', import.meta.env.MODE)
}
+44
View File
@@ -0,0 +1,44 @@
/**
* 全局错误处理
*/
import type { App } from 'vue'
import { ElMessage } from 'element-plus'
import { envConfig } from '@/config/env'
export function setupErrorHandler(app: App) {
// Vue错误处理
app.config.errorHandler = (error: any, instance, info) => {
console.error('Vue Error:', error)
console.error('Error Info:', info)
if (envConfig.debug) {
ElMessage.error(`Vue错误: ${error.message}`)
} else {
ElMessage.error('应用出现错误,请刷新页面重试')
}
}
// 全局未捕获的Promise错误
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled Promise Rejection:', event.reason)
if (envConfig.debug) {
ElMessage.error(`Promise错误: ${event.reason}`)
}
// 阻止默认的错误处理
event.preventDefault()
})
// 全局JavaScript错误
window.addEventListener('error', (event) => {
console.error('Global Error:', event.error)
if (envConfig.debug) {
ElMessage.error(`JavaScript错误: ${event.error?.message}`)
}
})
console.log('✅ 错误处理器设置完成')
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 全局组件注册
*/
import type { App } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
export function registerGlobalComponents(app: App) {
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
console.log('✅ 全局组件注册完成')
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 页面加载进度条
*/
import type { Router } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置NProgress
NProgress.configure({
showSpinner: false,
trickleSpeed: 200,
minimum: 0.3
})
export function setupProgressBar(router: Router) {
router.beforeEach((to, from, next) => {
// 开始进度条
NProgress.start()
next()
})
router.afterEach(() => {
// 完成进度条
NProgress.done()
})
console.log('✅ 进度条设置完成')
}
+159
View File
@@ -0,0 +1,159 @@
/**
* 路由守卫
* 处理认证、权限、页面标题等
*/
import type { Router } from 'vue-router'
import type { Pinia } from 'pinia'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { envConfig } from '@/config/env'
export function setupRouterGuards(router: Router, pinia: Pinia) {
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore(pinia)
const appStore = useAppStore(pinia)
// 开始加载
appStore.setLoading(true, '页面加载中...')
try {
// 检查是否需要认证
const requireAuth = to.meta.requireAuth !== false
// 如果需要认证但用户未登录
if (requireAuth && !authStore.isLoggedIn) {
// 检查是否有Token
if (authStore.token) {
try {
// 尝试获取用户信息
await authStore.checkAuthStatus()
// 认证成功,继续导航
next()
} catch (error) {
console.warn('认证检查失败:', error)
// 认证失败,跳转到登录页
ElMessage.warning('登录状态已过期,请重新登录')
next({
path: '/auth/login',
query: { redirect: to.fullPath }
})
}
} else {
// 没有Token,跳转到登录页
next({
path: '/auth/login',
query: { redirect: to.fullPath }
})
}
return
}
// 如果已登录但访问认证页面,跳转到首页
if (authStore.isLoggedIn && to.meta.hideForAuth) {
next('/home')
return
}
// 检查权限
if (to.meta.roles && to.meta.roles.length > 0) {
const hasRole = to.meta.roles.some((role: string) =>
authStore.hasRole(role)
)
if (!hasRole) {
ElMessage.error('权限不足,无法访问该页面')
next('/403')
return
}
}
// 检查权限
if (to.meta.permissions && to.meta.permissions.length > 0) {
const hasPermission = to.meta.permissions.some((permission: string) =>
authStore.hasPermission(permission)
)
if (!hasPermission) {
ElMessage.error('权限不足,无法访问该页面')
next('/403')
return
}
}
// 生产环境隐藏调试页面
if (to.meta.hideInProduction && !envConfig.debug) {
next('/404')
return
}
// 检查Token是否需要刷新
if (authStore.isLoggedIn) {
await authStore.refreshTokenIfNeeded()
}
next()
} catch (error) {
console.error('路由守卫错误:', error)
appStore.addError('页面加载失败', 'error')
next('/404')
}
})
// 全局后置守卫
router.afterEach((to, from) => {
const appStore = useAppStore(pinia)
// 结束加载
appStore.setLoading(false)
// 设置页面标题
const title = to.meta.title as string
if (title) {
document.title = `${title} - ${appStore.title}`
} else {
document.title = appStore.title
}
// 记录页面访问
if (envConfig.debug) {
console.log(`📄 页面访问: ${from.path} -> ${to.path}`)
}
// 埋点统计(如果需要)
// analytics.track('page_view', {
// page: to.path,
// title: to.meta.title
// })
})
// 路由错误处理
router.onError((error) => {
const appStore = useAppStore(pinia)
console.error('路由错误:', error)
appStore.setLoading(false)
appStore.addError('页面加载失败', 'error')
})
console.log('✅ 路由守卫设置完成')
}
// 扩展路由元信息类型
declare module 'vue-router' {
interface RouteMeta {
title?: string
requireAuth?: boolean
hideForAuth?: boolean
roles?: string[]
permissions?: string[]
hideInProduction?: boolean
layout?: string
transition?: string
icon?: string
}
}

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