feat: 完成情绪博物馆项目重构和功能增强 - 新增日记评论和帖子功能 - 重构前端架构,优化用户体验 - 完善WebSocket通信机制 - 更新项目文档和部署配置

This commit is contained in:
2025-07-27 10:05:59 +08:00
parent 6903ac1c0d
commit cc886cd4d5
126 changed files with 21179 additions and 15734 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -30,11 +30,19 @@
<module name="emotion-growth" />
<module name="emotion-record" />
</profile>
<profile name="Annotation profile for emotion-museum-server" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" />
</processorPath>
<module name="emotion-museum-server" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>
<module name="common" target="17" />
<module name="customer" target="17" />
<module name="emotion-museum-server" target="17" />
<module name="server" target="1.5" />
<module name="service" target="17" />
</bytecodeTargetLevel>
@@ -44,6 +52,7 @@
<module name="backend" options="" />
<module name="common" options="" />
<module name="customer" options="" />
<module name="emotion-museum-server" options="-parameters" />
</option>
</component>
</project>
+25
View File
@@ -11,11 +11,31 @@
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.huaweicloud.com/repository/maven/" />
</remote-repository>
<remote-repository>
<option name="id" value="spring-ai" />
<option name="name" value="Spring AI Repository" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="aliyun" />
<option name="name" value="Aliyun Maven Repository" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="spring-milestones" />
<option name="name" value="Spring Milestones" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="spring-snapshots" />
<option name="name" value="Spring Snapshots" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
@@ -31,5 +51,10 @@
<option name="name" value="Aliyun Maven Repository" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="aliyunmaven" />
<option name="name" value="Aliyun Maven Repository" />
<option name="url" value="https://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
</component>
</project>
@@ -111,6 +111,33 @@ public class AuthController {
return Result.success(username);
}
/**
* 检查账号是否存在
*/
@GetMapping("/check-account")
public Result<Boolean> checkAccount(@RequestParam String account) {
boolean exists = authService.existsByAccount(account);
return Result.success(exists);
}
/**
* 检查邮箱是否存在
*/
@GetMapping("/check-email")
public Result<Boolean> checkEmail(@RequestParam String email) {
boolean exists = authService.existsByEmail(email);
return Result.success(exists);
}
/**
* 检查手机号是否存在
*/
@GetMapping("/check-phone")
public Result<Boolean> checkPhone(@RequestParam String phone) {
boolean exists = authService.existsByPhone(phone);
return Result.success(exists);
}
/**
* 从请求中提取访问令牌
*/
@@ -0,0 +1,332 @@
package com.emotion.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotion.common.BasePageRequest;
import com.emotion.common.PageResult;
import com.emotion.common.Result;
import com.emotion.dto.request.DiaryCommentCreateRequest;
import com.emotion.dto.response.DiaryCommentResponse;
import com.emotion.entity.DiaryComment;
import com.emotion.service.DiaryCommentService;
import com.emotion.service.DiaryPostService;
import com.emotion.service.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
/**
* 日记评论控制器
*
* @author emotion-museum
* @date 2025-07-23
*/
@RestController
@RequestMapping("/diary-comment")
public class DiaryCommentController {
@Autowired
private DiaryCommentService diaryCommentService;
@Autowired
private DiaryPostService diaryPostService;
@Autowired
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 分页查询评论
*/
@GetMapping("/page")
public Result<PageResult<DiaryCommentResponse>> getPage(@Validated BasePageRequest request) {
IPage<DiaryComment> page = diaryCommentService.getPage(request);
List<DiaryCommentResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryCommentResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 根据日记ID分页查询评论
*/
@GetMapping("/diary/{diaryId}/page")
public Result<PageResult<DiaryCommentResponse>> getPageByDiaryId(@PathVariable String diaryId,
@Validated BasePageRequest request) {
IPage<DiaryComment> page = diaryCommentService.getPageByDiaryId(diaryId, request);
List<DiaryCommentResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryCommentResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 根据用户ID分页查询评论
*/
@GetMapping("/user/{userId}/page")
public Result<PageResult<DiaryCommentResponse>> getPageByUserId(@PathVariable String userId,
@Validated BasePageRequest request) {
IPage<DiaryComment> page = diaryCommentService.getPageByUserId(userId, request);
List<DiaryCommentResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryCommentResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 获取评论树结构
*/
@GetMapping("/diary/{diaryId}/tree")
public Result<List<DiaryCommentResponse>> getCommentTree(@PathVariable String diaryId) {
List<DiaryComment> comments = diaryCommentService.getCommentTree(diaryId);
List<DiaryCommentResponse> responses = comments.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
return Result.success(responses);
}
/**
* 根据ID获取评论详情
*/
@GetMapping("/{id}")
public Result<DiaryCommentResponse> getById(@PathVariable String id) {
DiaryComment comment = diaryCommentService.getById(id);
if (comment == null) {
return Result.notFound("评论不存在");
}
return Result.success(convertToResponse(comment));
}
/**
* 创建评论
*/
@PostMapping
public Result<DiaryCommentResponse> create(@Valid @RequestBody DiaryCommentCreateRequest request) {
DiaryComment comment = diaryCommentService.createComment(
request.getDiaryId(),
request.getUserId(),
request.getContent(),
request.getImages(),
request.getParentCommentId(),
request.getIsAnonymous()
);
// 更新日记的评论数
diaryPostService.incrementCommentCount(request.getDiaryId());
diaryPostService.updateLastCommentTime(request.getDiaryId());
return Result.success(convertToResponse(comment));
}
/**
* 更新评论
*/
@PutMapping("/{id}")
public Result<DiaryCommentResponse> update(@PathVariable String id, @Valid @RequestBody DiaryCommentCreateRequest request) {
boolean updated = diaryCommentService.updateComment(id, request.getContent(), request.getImages());
if (!updated) {
return Result.error("更新失败");
}
DiaryComment updatedComment = diaryCommentService.getById(id);
return Result.success(convertToResponse(updatedComment));
}
/**
* 删除评论
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
DiaryComment comment = diaryCommentService.getById(id);
if (comment == null) {
return Result.error("评论不存在");
}
boolean deleted = diaryCommentService.deleteComment(id);
if (!deleted) {
return Result.error("删除失败");
}
// 更新日记的评论数
diaryPostService.decrementCommentCount(comment.getDiaryId());
return Result.success();
}
/**
* 软删除评论
*/
@DeleteMapping("/{id}/soft")
public Result<Void> softDelete(@PathVariable String id) {
DiaryComment comment = diaryCommentService.getById(id);
if (comment == null) {
return Result.error("评论不存在");
}
boolean deleted = diaryCommentService.softDeleteComment(id);
if (!deleted) {
return Result.error("删除失败");
}
// 更新日记的评论数
diaryPostService.decrementCommentCount(comment.getDiaryId());
return Result.success();
}
/**
* 恢复评论
*/
@PutMapping("/{id}/restore")
public Result<Void> restore(@PathVariable String id) {
DiaryComment comment = diaryCommentService.getById(id);
if (comment == null) {
return Result.error("评论不存在");
}
boolean restored = diaryCommentService.restoreComment(id);
if (!restored) {
return Result.error("恢复失败");
}
// 更新日记的评论数
diaryPostService.incrementCommentCount(comment.getDiaryId());
return Result.success();
}
/**
* 点赞评论
*/
@PostMapping("/{id}/like")
public Result<Void> like(@PathVariable String id) {
boolean liked = diaryCommentService.incrementLikeCount(id);
if (!liked) {
return Result.error("点赞失败");
}
return Result.success();
}
/**
* 取消点赞评论
*/
@DeleteMapping("/{id}/like")
public Result<Void> unlike(@PathVariable String id) {
boolean unliked = diaryCommentService.decrementLikeCount(id);
if (!unliked) {
return Result.error("取消点赞失败");
}
return Result.success();
}
/**
* 设置置顶状态
*/
@PutMapping("/{id}/top/{isTop}")
public Result<Void> setTop(@PathVariable String id, @PathVariable Integer isTop) {
boolean set = diaryCommentService.setTop(id, isTop);
if (!set) {
return Result.error("设置置顶状态失败");
}
return Result.success();
}
/**
* 统计日记评论数量
*/
@GetMapping("/diary/{diaryId}/count")
public Result<Long> countByDiaryId(@PathVariable String diaryId) {
Long count = diaryCommentService.countByDiaryId(diaryId);
return Result.success(count);
}
/**
* 统计用户评论数量
*/
@GetMapping("/user/{userId}/count")
public Result<Long> countByUserId(@PathVariable String userId) {
Long count = diaryCommentService.countByUserId(userId);
return Result.success(count);
}
/**
* 统计回复数量
*/
@GetMapping("/parent/{parentCommentId}/replies/count")
public Result<Long> countReplies(@PathVariable String parentCommentId) {
Long count = diaryCommentService.countReplies(parentCommentId);
return Result.success(count);
}
/**
* 转换实体为响应DTO
*/
private DiaryCommentResponse convertToResponse(DiaryComment comment) {
DiaryCommentResponse response = new DiaryCommentResponse();
BeanUtils.copyProperties(comment, response);
// 转换时间格式
if (comment.getPublishTime() != null) {
response.setPublishTime(comment.getPublishTime().format(DATE_TIME_FORMATTER));
}
if (comment.getLastReplyTime() != null) {
response.setLastReplyTime(comment.getLastReplyTime().format(DATE_TIME_FORMATTER));
}
if (comment.getCreateTime() != null) {
response.setCreateTime(comment.getCreateTime().format(DATE_TIME_FORMATTER));
}
if (comment.getUpdateTime() != null) {
response.setUpdateTime(comment.getUpdateTime().format(DATE_TIME_FORMATTER));
}
// 转换JSON字段
try {
if (comment.getImages() != null) {
response.setImages(objectMapper.readValue(comment.getImages(), new TypeReference<List<String>>() {}));
}
if (comment.getMetadata() != null) {
response.setMetadata(objectMapper.readValue(comment.getMetadata(), Object.class));
}
} catch (JsonProcessingException e) {
// 忽略JSON解析错误
}
return response;
}
}
@@ -0,0 +1,359 @@
package com.emotion.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.emotion.common.BasePageRequest;
import com.emotion.common.PageResult;
import com.emotion.common.Result;
import com.emotion.dto.request.DiaryPostCreateRequest;
import com.emotion.dto.request.DiaryPostUpdateRequest;
import com.emotion.dto.response.DiaryPostResponse;
import com.emotion.entity.DiaryPost;
import com.emotion.service.DiaryPostService;
import com.emotion.service.DiaryCommentService;
import com.emotion.service.UserService;
import com.emotion.service.AiChatService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import com.emotion.entity.DiaryComment;
/**
* 用户日记控制器
*
* @author emotion-museum
* @date 2025-07-23
*/
@RestController
@RequestMapping("/diary-post")
public class DiaryPostController {
@Autowired
private DiaryPostService diaryPostService;
@Autowired
private DiaryCommentService diaryCommentService;
@Autowired
private UserService userService;
@Autowired
private AiChatService aiChatService;
@Autowired
private ObjectMapper objectMapper;
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 分页查询日记
*/
@GetMapping("/page")
public Result<PageResult<DiaryPostResponse>> getPage(@Validated BasePageRequest request) {
IPage<DiaryPost> page = diaryPostService.getPage(request);
List<DiaryPostResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryPostResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 根据用户ID分页查询日记
*/
@GetMapping("/user/{userId}/page")
public Result<PageResult<DiaryPostResponse>> getPageByUserId(@PathVariable String userId,
@Validated BasePageRequest request) {
IPage<DiaryPost> page = diaryPostService.getPageByUserId(userId, request);
List<DiaryPostResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryPostResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 根据用户ID查询公开日记
*/
@GetMapping("/user/{userId}/public/page")
public Result<PageResult<DiaryPostResponse>> getPublicPageByUserId(@PathVariable String userId,
@Validated BasePageRequest request) {
IPage<DiaryPost> page = diaryPostService.getPublicPageByUserId(userId, request);
List<DiaryPostResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryPostResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 查询精选日记
*/
@GetMapping("/featured/page")
public Result<PageResult<DiaryPostResponse>> getFeaturedPage(@Validated BasePageRequest request) {
IPage<DiaryPost> page = diaryPostService.getFeaturedPage(request);
List<DiaryPostResponse> responses = page.getRecords().stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
PageResult<DiaryPostResponse> pageResult = new PageResult<>();
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
pageResult.setPages(page.getPages());
pageResult.setRecords(responses);
return Result.success(pageResult);
}
/**
* 根据ID获取日记详情
*/
@GetMapping("/{id}")
public Result<DiaryPostResponse> getById(@PathVariable String id) {
DiaryPost diaryPost = diaryPostService.getById(id);
if (diaryPost == null) {
return Result.notFound("日记不存在");
}
// 增加浏览数
diaryPostService.incrementViewCount(id);
return Result.success(convertToResponse(diaryPost));
}
/**
* 创建日记
*/
@PostMapping
public Result<DiaryPostResponse> create(@Valid @RequestBody DiaryPostCreateRequest request) {
DiaryPost diaryPost = diaryPostService.createDiaryPost(request);
return Result.success(convertToResponse(diaryPost));
}
/**
* 发表日记并生成AI评论
*/
@PostMapping("/publish")
public Result<DiaryPostResponse> publish(@Valid @RequestBody DiaryPostCreateRequest request) {
return Result.success(diaryPostService.publishDiaryWithAiComment(request));
}
/**
* 更新日记
*/
@PutMapping("/{id}")
public Result<DiaryPostResponse> update(@PathVariable String id, @Valid @RequestBody DiaryPostUpdateRequest request) {
boolean updated = diaryPostService.updateDiaryPost(id, request);
if (!updated) {
return Result.error("更新失败");
}
DiaryPost updatedDiaryPost = diaryPostService.getById(id);
return Result.success(convertToResponse(updatedDiaryPost));
}
/**
* 删除日记
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
boolean deleted = diaryPostService.deleteDiaryPost(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
/**
* 软删除日记
*/
@DeleteMapping("/{id}/soft")
public Result<Void> softDelete(@PathVariable String id) {
boolean deleted = diaryPostService.softDeleteDiaryPost(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
/**
* 恢复日记
*/
@PutMapping("/{id}/restore")
public Result<Void> restore(@PathVariable String id) {
boolean restored = diaryPostService.restoreDiaryPost(id);
if (!restored) {
return Result.error("恢复失败");
}
return Result.success();
}
/**
* 点赞日记
*/
@PostMapping("/{id}/like")
public Result<Void> like(@PathVariable String id) {
boolean liked = diaryPostService.incrementLikeCount(id);
if (!liked) {
return Result.error("点赞失败");
}
return Result.success();
}
/**
* 取消点赞日记
*/
@DeleteMapping("/{id}/like")
public Result<Void> unlike(@PathVariable String id) {
boolean unliked = diaryPostService.decrementLikeCount(id);
if (!unliked) {
return Result.error("取消点赞失败");
}
return Result.success();
}
/**
* 分享日记
*/
@PostMapping("/{id}/share")
public Result<Void> share(@PathVariable String id) {
boolean shared = diaryPostService.incrementShareCount(id);
if (!shared) {
return Result.error("分享失败");
}
return Result.success();
}
/**
* 设置精选状态
*/
@PutMapping("/{id}/featured/{featured}")
public Result<Void> setFeatured(@PathVariable String id, @PathVariable Integer featured) {
boolean set = diaryPostService.setFeatured(id, featured);
if (!set) {
return Result.error("设置精选状态失败");
}
return Result.success();
}
/**
* 设置置顶优先级
*/
@PutMapping("/{id}/priority/{priority}")
public Result<Void> setPriority(@PathVariable String id, @PathVariable Integer priority) {
boolean set = diaryPostService.setPriority(id, priority);
if (!set) {
return Result.error("设置优先级失败");
}
return Result.success();
}
/**
* 统计用户日记数量
*/
@GetMapping("/user/{userId}/count")
public Result<Long> countByUserId(@PathVariable String userId) {
Long count = diaryPostService.countByUserId(userId);
return Result.success(count);
}
/**
* 统计用户公开日记数量
*/
@GetMapping("/user/{userId}/public/count")
public Result<Long> countPublicByUserId(@PathVariable String userId) {
Long count = diaryPostService.countPublicByUserId(userId);
return Result.success(count);
}
/**
* 统计精选日记数量
*/
@GetMapping("/featured/count")
public Result<Long> countFeatured() {
Long count = diaryPostService.countFeatured();
return Result.success(count);
}
/**
* 转换实体为响应DTO
*/
private DiaryPostResponse convertToResponse(DiaryPost diaryPost) {
DiaryPostResponse response = new DiaryPostResponse();
BeanUtils.copyProperties(diaryPost, response);
// 转换时间格式
if (diaryPost.getPublishTime() != null) {
response.setPublishTime(diaryPost.getPublishTime().format(DATE_TIME_FORMATTER));
}
if (diaryPost.getLastCommentTime() != null) {
response.setLastCommentTime(diaryPost.getLastCommentTime().format(DATE_TIME_FORMATTER));
}
if (diaryPost.getAiCommentTime() != null) {
response.setAiCommentTime(diaryPost.getAiCommentTime().format(DATE_TIME_FORMATTER));
}
if (diaryPost.getCreateTime() != null) {
response.setCreateTime(diaryPost.getCreateTime().format(DATE_TIME_FORMATTER));
}
if (diaryPost.getUpdateTime() != null) {
response.setUpdateTime(diaryPost.getUpdateTime().format(DATE_TIME_FORMATTER));
}
// 转换JSON字段
try {
if (diaryPost.getImages() != null) {
response.setImages(objectMapper.readValue(diaryPost.getImages(), new TypeReference<List<String>>() {}));
}
if (diaryPost.getVideos() != null) {
response.setVideos(objectMapper.readValue(diaryPost.getVideos(), new TypeReference<List<String>>() {}));
}
if (diaryPost.getTags() != null) {
response.setTags(objectMapper.readValue(diaryPost.getTags(), new TypeReference<List<String>>() {}));
}
if (diaryPost.getAiKeywords() != null) {
response.setAiKeywords(objectMapper.readValue(diaryPost.getAiKeywords(), new TypeReference<List<String>>() {}));
}
if (diaryPost.getAiEmotionAnalysis() != null) {
response.setAiEmotionAnalysis(objectMapper.readValue(diaryPost.getAiEmotionAnalysis(), Object.class));
}
if (diaryPost.getMetadata() != null) {
response.setMetadata(objectMapper.readValue(diaryPost.getMetadata(), Object.class));
}
} catch (JsonProcessingException e) {
// 忽略JSON解析错误
}
return response;
}
}
@@ -6,14 +6,18 @@ import com.emotion.common.PageResult;
import com.emotion.common.Result;
import com.emotion.dto.request.UserCreateRequest;
import com.emotion.dto.request.UserUpdateRequest;
import com.emotion.dto.request.UserProfileUpdateRequest;
import com.emotion.dto.response.UserResponse;
import com.emotion.entity.User;
import com.emotion.service.UserService;
import com.emotion.util.UserContextHolder;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import java.time.format.DateTimeFormatter;
import java.util.List;
@@ -132,6 +136,67 @@ public class UserController {
return Result.success(count);
}
/**
* 获取当前用户个人资料
*/
@GetMapping("/profile")
public Result<UserResponse> getCurrentUserProfile(HttpServletRequest request) {
String currentUserId = getCurrentUserId(request);
if (currentUserId == null) {
return Result.unauthorized("用户未登录");
}
User user = userService.getById(currentUserId);
if (user == null) {
return Result.notFound("用户不存在");
}
return Result.success(convertToResponse(user));
}
/**
* 更新当前用户个人资料
*/
@PutMapping("/profile")
public Result<UserResponse> updateCurrentUserProfile(@Valid @RequestBody UserProfileUpdateRequest request,
HttpServletRequest httpRequest) {
String currentUserId = getCurrentUserId(httpRequest);
if (currentUserId == null) {
return Result.unauthorized("用户未登录");
}
User user = new User();
BeanUtils.copyProperties(request, user);
user.setId(currentUserId);
boolean updated = userService.updateById(user);
if (!updated) {
return Result.error("更新失败");
}
User updatedUser = userService.getById(currentUserId);
return Result.success(convertToResponse(updatedUser));
}
/**
* 获取当前用户ID
* 从JWT拦截器设置的请求属性中获取用户ID
*/
private String getCurrentUserId(HttpServletRequest request) {
// 优先从UserContextHolder获取(线程本地存储)
String userId = UserContextHolder.getCurrentUserId();
if (userId != null) {
return userId;
}
// 如果UserContextHolder中没有,从请求属性中获取(兼容性)
Object userIdAttr = request.getAttribute("userId");
if (userIdAttr != null) {
return userIdAttr.toString();
}
return null;
}
/**
* 转换为响应对象
*/
@@ -0,0 +1,52 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
/**
* 创建日记评论请求DTO
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
public class DiaryCommentCreateRequest {
/**
* 日记ID
*/
@NotBlank(message = "日记ID不能为空")
private String diaryId;
/**
* 用户ID
*/
@NotBlank(message = "用户ID不能为空")
private String userId;
/**
* 评论内容
*/
@NotBlank(message = "评论内容不能为空")
private String content;
/**
* 评论图片
*/
private List<String> images;
/**
* 父评论ID (用于回复功能)
*/
private String parentCommentId;
/**
* 是否匿名评论: 0-实名, 1-匿名
*/
@NotNull(message = "是否匿名不能为空")
private Integer isAnonymous;
}
@@ -0,0 +1,97 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/**
* 创建日记请求DTO
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
public class DiaryPostCreateRequest {
/**
* 日记标题
*/
@Size(max = 200, message = "日记标题长度不能超过200个字符")
private String title;
/**
* 日记内容
*/
@NotBlank(message = "日记内容不能为空")
private String content;
/**
* 图片列表
*/
private List<String> images;
/**
* 视频列表
*/
private List<String> videos;
/**
* 发布地点
*/
@Size(max = 200, message = "发布地点长度不能超过200个字符")
private String location;
/**
* 纬度
*/
private BigDecimal latitude;
/**
* 经度
*/
private BigDecimal longitude;
/**
* 天气信息
*/
@Size(max = 50, message = "天气信息长度不能超过50个字符")
private String weather;
/**
* 心情状态
*/
@Size(max = 50, message = "心情状态长度不能超过50个字符")
private String mood;
/**
* 心情评分 (0-10)
*/
private BigDecimal moodScore;
/**
* 标签列表
*/
private List<String> tags;
/**
* 是否公开: 0-仅自己可见, 1-公开
*/
@NotNull(message = "是否公开不能为空")
private Integer isPublic;
/**
* 是否匿名发布: 0-实名, 1-匿名
*/
@NotNull(message = "是否匿名不能为空")
private Integer isAnonymous;
/**
* 用户ID
*/
@NotBlank(message = "用户ID不能为空")
private String userId;
}
@@ -0,0 +1,91 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/**
* 更新日记请求DTO
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
public class DiaryPostUpdateRequest {
/**
* 日记标题
*/
@Size(max = 200, message = "日记标题长度不能超过200个字符")
private String title;
/**
* 日记内容
*/
private String content;
/**
* 图片列表
*/
private List<String> images;
/**
* 视频列表
*/
private List<String> videos;
/**
* 发布地点
*/
@Size(max = 200, message = "发布地点长度不能超过200个字符")
private String location;
/**
* 纬度
*/
private BigDecimal latitude;
/**
* 经度
*/
private BigDecimal longitude;
/**
* 天气信息
*/
@Size(max = 50, message = "天气信息长度不能超过50个字符")
private String weather;
/**
* 心情状态
*/
@Size(max = 50, message = "心情状态长度不能超过50个字符")
private String mood;
/**
* 心情评分 (0-10)
*/
private BigDecimal moodScore;
/**
* 标签列表
*/
private List<String> tags;
/**
* 是否公开: 0-仅自己可见, 1-公开
*/
private Integer isPublic;
/**
* 是否匿名发布: 0-实名, 1-匿名
*/
private Integer isAnonymous;
/**
* 状态: draft-草稿, published-已发布, hidden-隐藏, deleted-已删除
*/
private String status;
}
@@ -32,6 +32,12 @@ public class RegisterRequest extends BaseRequest {
@Size(min = 6, max = 20, message = "密码长度必须在6-20个字符之间")
private String password;
/**
* 确认密码
*/
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
/**
* 用户名
*/
@@ -51,7 +57,7 @@ public class RegisterRequest extends BaseRequest {
/**
* 手机号
*/
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
@@ -0,0 +1,60 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDate;
/**
* 用户个人资料更新请求类
*
* @author emotion-museum
* @date 2025-07-26
*/
@Data
public class UserProfileUpdateRequest {
/**
* 昵称
*/
@Size(max = 50, message = "昵称长度不能超过50个字符")
private String nickname;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 100, message = "邮箱长度不能超过100个字符")
private String email;
/**
* 手机号
*/
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 头像URL
*/
@Size(max = 500, message = "头像URL长度不能超过500个字符")
private String avatar;
/**
* 生日
*/
private LocalDate birthDate;
/**
* 所在地
*/
@Size(max = 100, message = "所在地长度不能超过100个字符")
private String location;
/**
* 个人简介
*/
@Size(max = 500, message = "个人简介长度不能超过500个字符")
private String bio;
}
@@ -5,6 +5,8 @@ import lombok.EqualsAndHashCode;
import javax.validation.constraints.Email;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDate;
/**
* 用户更新请求类
@@ -19,17 +21,20 @@ public class UserUpdateRequest extends BaseRequest {
/**
* 用户名
*/
@Size(max = 50, message = "用户名长度不能超过50个字符")
private String username;
/**
* 昵称
*/
@Size(max = 50, message = "昵称长度不能超过50个字符")
private String nickname;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 100, message = "邮箱长度不能超过100个字符")
private String email;
/**
@@ -39,10 +44,28 @@ public class UserUpdateRequest extends BaseRequest {
private String phone;
/**
* 头像
* 头像URL
*/
@Size(max = 500, message = "头像URL长度不能超过500个字符")
private String avatar;
/**
* 生日
*/
private LocalDate birthDate;
/**
* 所在地
*/
@Size(max = 100, message = "所在地长度不能超过100个字符")
private String location;
/**
* 个人简介
*/
@Size(max = 500, message = "个人简介长度不能超过500个字符")
private String bio;
/**
* 状态
*/
@@ -0,0 +1,136 @@
package com.emotion.dto.response;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 日记评论响应DTO
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
public class DiaryCommentResponse {
/**
* 评论ID
*/
private String id;
/**
* 日记ID
*/
private String diaryId;
/**
* 评论用户ID
*/
private String userId;
/**
* 评论用户信息
*/
private UserInfoResponse userInfo;
/**
* 父评论ID (用于回复功能)
*/
private String parentCommentId;
/**
* 父评论信息
*/
private DiaryCommentResponse parentComment;
/**
* 评论内容
*/
private String content;
/**
* 评论图片
*/
private List<String> images;
/**
* 评论类型: user-用户评论, ai-AI评论, system-系统评论
*/
private String commentType;
/**
* AI评论来源 (如: emotion_analysis, sentiment_analysis)
*/
private String aiCommentSource;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 是否匿名评论: 0-实名, 1-匿名
*/
private Integer isAnonymous;
/**
* 是否置顶: 0-普通, 1-置顶
*/
private Integer isTop;
/**
* 状态: published-已发布, hidden-隐藏, deleted-已删除
*/
private String status;
/**
* 发布时间
*/
private String publishTime;
/**
* 最后回复时间
*/
private String lastReplyTime;
/**
* 情绪评分
*/
private BigDecimal emotionScore;
/**
* 情感评分
*/
private BigDecimal sentimentScore;
/**
* 扩展元数据
*/
private Object metadata;
/**
* 创建时间
*/
private String createTime;
/**
* 更新时间
*/
private String updateTime;
/**
* 是否已点赞
*/
private Boolean isLiked;
/**
* 子评论列表
*/
private List<DiaryCommentResponse> replies;
}
@@ -0,0 +1,197 @@
package com.emotion.dto.response;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 日记响应DTO
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
public class DiaryPostResponse {
/**
* 日记ID
*/
private String id;
/**
* 用户ID
*/
private String userId;
/**
* 用户信息
*/
private UserInfoResponse userInfo;
/**
* 日记标题
*/
private String title;
/**
* 日记内容
*/
private String content;
/**
* 图片列表
*/
private List<String> images;
/**
* 视频列表
*/
private List<String> videos;
/**
* 发布地点
*/
private String location;
/**
* 纬度
*/
private BigDecimal latitude;
/**
* 经度
*/
private BigDecimal longitude;
/**
* 天气信息
*/
private String weather;
/**
* 心情状态
*/
private String mood;
/**
* 心情评分 (0-10)
*/
private BigDecimal moodScore;
/**
* 标签列表
*/
private List<String> tags;
/**
* 是否公开: 0-仅自己可见, 1-公开
*/
private Integer isPublic;
/**
* 是否匿名发布: 0-实名, 1-匿名
*/
private Integer isAnonymous;
/**
* 浏览数
*/
private Integer viewCount;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 评论数
*/
private Integer commentCount;
/**
* 分享数
*/
private Integer shareCount;
/**
* AI评论内容
*/
private String aiComment;
/**
* AI评论时间
*/
private String aiCommentTime;
/**
* AI情绪分析结果
*/
private Object aiEmotionAnalysis;
/**
* AI情感评分 (-1到1)
*/
private BigDecimal aiSentimentScore;
/**
* AI提取的关键词
*/
private List<String> aiKeywords;
/**
* AI建议
*/
private String aiSuggestions;
/**
* 发布时间
*/
private String publishTime;
/**
* 最后评论时间
*/
private String lastCommentTime;
/**
* 状态: draft-草稿, published-已发布, hidden-隐藏, deleted-已删除
*/
private String status;
/**
* 优先级 (用于置顶功能)
*/
private Integer priority;
/**
* 是否精选: 0-普通, 1-精选
*/
private Integer featured;
/**
* 扩展元数据
*/
private Object metadata;
/**
* 创建时间
*/
private String createTime;
/**
* 更新时间
*/
private String updateTime;
/**
* 是否已点赞
*/
private Boolean isLiked;
/**
* 是否已收藏
*/
private Boolean isBookmarked;
}
@@ -0,0 +1,129 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.emotion.common.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 日记评论实体类
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("diary_comment")
public class DiaryComment extends BaseEntity {
/**
* 日记ID
*/
@TableField("diary_id")
private String diaryId;
/**
* 评论用户ID
*/
@TableField("user_id")
private String userId;
/**
* 父评论ID (用于回复功能)
*/
@TableField("parent_comment_id")
private String parentCommentId;
/**
* 评论内容
*/
@TableField("content")
private String content;
/**
* 评论图片 (存储图片URL数组)
*/
@TableField("images")
private String images;
/**
* 评论类型: user-用户评论, ai-AI评论, system-系统评论
*/
@TableField("comment_type")
private String commentType;
/**
* AI评论来源 (如: emotion_analysis, sentiment_analysis)
*/
@TableField("ai_comment_source")
private String aiCommentSource;
/**
* 点赞数
*/
@TableField("like_count")
private Integer likeCount;
/**
* 回复数
*/
@TableField("reply_count")
private Integer replyCount;
/**
* 是否匿名评论: 0-实名, 1-匿名
*/
@TableField("is_anonymous")
private Integer isAnonymous;
/**
* 是否置顶: 0-普通, 1-置顶
*/
@TableField("is_top")
private Integer isTop;
/**
* 状态: published-已发布, hidden-隐藏, deleted-已删除
*/
@TableField("status")
private String status;
/**
* 发布时间
*/
@TableField("publish_time")
private LocalDateTime publishTime;
/**
* 最后回复时间
*/
@TableField("last_reply_time")
private LocalDateTime lastReplyTime;
/**
* 情绪评分
*/
@TableField("emotion_score")
private BigDecimal emotionScore;
/**
* 情感评分
*/
@TableField("sentiment_score")
private BigDecimal sentimentScore;
/**
* 扩展元数据
*/
@TableField("metadata")
private String metadata;
}
@@ -0,0 +1,207 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.emotion.common.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 用户日记实体类
*
* @author emotion-museum
* @date 2025-07-23
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("diary_post")
public class DiaryPost extends BaseEntity {
/**
* 用户ID
*/
@TableField("user_id")
private String userId;
/**
* 日记标题
*/
@TableField("title")
private String title;
/**
* 日记内容
*/
@TableField("content")
private String content;
/**
* 图片列表 (存储图片URL数组)
*/
@TableField("images")
private String images;
/**
* 视频列表 (存储视频URL数组)
*/
@TableField("videos")
private String videos;
/**
* 发布地点
*/
@TableField("location")
private String location;
/**
* 纬度
*/
@TableField("latitude")
private BigDecimal latitude;
/**
* 经度
*/
@TableField("longitude")
private BigDecimal longitude;
/**
* 天气信息
*/
@TableField("weather")
private String weather;
/**
* 心情状态
*/
@TableField("mood")
private String mood;
/**
* 心情评分 (0-10)
*/
@TableField("mood_score")
private BigDecimal moodScore;
/**
* 标签列表
*/
@TableField("tags")
private String tags;
/**
* 是否公开: 0-仅自己可见, 1-公开
*/
@TableField("is_public")
private Integer isPublic;
/**
* 是否匿名发布: 0-实名, 1-匿名
*/
@TableField("is_anonymous")
private Integer isAnonymous;
/**
* 浏览数
*/
@TableField("view_count")
private Integer viewCount;
/**
* 点赞数
*/
@TableField("like_count")
private Integer likeCount;
/**
* 评论数
*/
@TableField("comment_count")
private Integer commentCount;
/**
* 分享数
*/
@TableField("share_count")
private Integer shareCount;
/**
* AI评论内容
*/
@TableField("ai_comment")
private String aiComment;
/**
* AI评论时间
*/
@TableField("ai_comment_time")
private LocalDateTime aiCommentTime;
/**
* AI情绪分析结果
*/
@TableField("ai_emotion_analysis")
private String aiEmotionAnalysis;
/**
* AI情感评分 (-1到1)
*/
@TableField("ai_sentiment_score")
private BigDecimal aiSentimentScore;
/**
* AI提取的关键词
*/
@TableField("ai_keywords")
private String aiKeywords;
/**
* AI建议
*/
@TableField("ai_suggestions")
private String aiSuggestions;
/**
* 发布时间
*/
@TableField("publish_time")
private LocalDateTime publishTime;
/**
* 最后评论时间
*/
@TableField("last_comment_time")
private LocalDateTime lastCommentTime;
/**
* 状态: draft-草稿, published-已发布, hidden-隐藏, deleted-已删除
*/
@TableField("status")
private String status;
/**
* 优先级 (用于置顶功能)
*/
@TableField("priority")
private Integer priority;
/**
* 是否精选: 0-普通, 1-精选
*/
@TableField("featured")
private Integer featured;
/**
* 扩展元数据
*/
@TableField("metadata")
private String metadata;
}
@@ -0,0 +1,15 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.DiaryComment;
import org.apache.ibatis.annotations.Mapper;
/**
* 日记评论Mapper接口
*
* @author emotion-museum
* @date 2025-07-23
*/
@Mapper
public interface DiaryCommentMapper extends BaseMapper<DiaryComment> {
}
@@ -0,0 +1,15 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.DiaryPost;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户日记Mapper接口
*
* @author emotion-museum
* @date 2025-07-23
*/
@Mapper
public interface DiaryPostMapper extends BaseMapper<DiaryPost> {
}
@@ -117,4 +117,13 @@ public interface AiChatService {
* @return 包含情绪总结等信息的CompletableFuture
*/
java.util.concurrent.CompletableFuture<java.util.Map<String, Object>> generateEmotionSummaryAsync(String userId);
/**
* 发送日记内容生成AI总结评论
* @param conversationId 会话ID
* @param userMessage 用户日记内容
* @param userId 用户ID
* @return AI评论内容
*/
String sendSummaryMessage(String conversationId, String userMessage, String userId);
}
@@ -102,4 +102,28 @@ public interface AuthService {
* @return 用户名
*/
String getUsernameFromToken(String token);
/**
* 检查账号是否存在
*
* @param account 账号
* @return 是否存在
*/
boolean existsByAccount(String account);
/**
* 检查邮箱是否存在
*
* @param email 邮箱
* @return 是否存在
*/
boolean existsByEmail(String email);
/**
* 检查手机号是否存在
*
* @param phone 手机号
* @return 是否存在
*/
boolean existsByPhone(String phone);
}
@@ -0,0 +1,135 @@
package com.emotion.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.common.BasePageRequest;
import com.emotion.entity.DiaryComment;
import java.math.BigDecimal;
import java.util.List;
/**
* 日记评论服务接口
*
* @author emotion-museum
* @date 2025-07-23
*/
public interface DiaryCommentService extends IService<DiaryComment> {
/**
* 分页查询评论
*/
IPage<DiaryComment> getPage(BasePageRequest request);
/**
* 根据日记ID分页查询评论
*/
IPage<DiaryComment> getPageByDiaryId(String diaryId, BasePageRequest request);
/**
* 根据用户ID分页查询评论
*/
IPage<DiaryComment> getPageByUserId(String userId, BasePageRequest request);
/**
* 根据父评论ID查询回复列表
*/
List<DiaryComment> getRepliesByParentId(String parentCommentId);
/**
* 根据评论类型查询评论列表
*/
List<DiaryComment> getByCommentType(String commentType);
/**
* 根据日记ID和评论类型查询评论列表
*/
List<DiaryComment> getByDiaryIdAndCommentType(String diaryId, String commentType);
/**
* 增加点赞数
*/
boolean incrementLikeCount(String commentId);
/**
* 减少点赞数
*/
boolean decrementLikeCount(String commentId);
/**
* 增加回复数
*/
boolean incrementReplyCount(String commentId);
/**
* 减少回复数
*/
boolean decrementReplyCount(String commentId);
/**
* 更新最后回复时间
*/
boolean updateLastReplyTime(String commentId);
/**
* 设置置顶状态
*/
boolean setTop(String commentId, Integer isTop);
/**
* 统计日记评论数量
*/
Long countByDiaryId(String diaryId);
/**
* 统计用户评论数量
*/
Long countByUserId(String userId);
/**
* 统计指定类型的评论数量
*/
Long countByCommentType(String commentType);
/**
* 统计回复数量
*/
Long countReplies(String parentCommentId);
/**
* 创建评论
*/
DiaryComment createComment(String diaryId, String userId, String content, List<String> images,
String parentCommentId, Integer isAnonymous);
/**
* 创建AI评论
*/
DiaryComment createAiComment(String diaryId, String content, String aiCommentSource,
BigDecimal emotionScore, BigDecimal sentimentScore);
/**
* 更新评论
*/
boolean updateComment(String commentId, String content, List<String> images);
/**
* 删除评论
*/
boolean deleteComment(String commentId);
/**
* 软删除评论
*/
boolean softDeleteComment(String commentId);
/**
* 恢复评论
*/
boolean restoreComment(String commentId);
/**
* 获取评论树结构
*/
List<DiaryComment> getCommentTree(String diaryId);
}
@@ -0,0 +1,166 @@
package com.emotion.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.common.BasePageRequest;
import com.emotion.entity.DiaryPost;
import java.math.BigDecimal;
import java.util.List;
/**
* 用户日记服务接口
*
* @author emotion-museum
* @date 2025-07-23
*/
public interface DiaryPostService extends IService<DiaryPost> {
/**
* 分页查询日记
*/
IPage<DiaryPost> getPage(BasePageRequest request);
/**
* 根据用户ID分页查询日记
*/
IPage<DiaryPost> getPageByUserId(String userId, BasePageRequest request);
/**
* 根据用户ID查询公开日记
*/
IPage<DiaryPost> getPublicPageByUserId(String userId, BasePageRequest request);
/**
* 查询精选日记
*/
IPage<DiaryPost> getFeaturedPage(BasePageRequest request);
/**
* 根据状态查询日记列表
*/
List<DiaryPost> getByStatus(String status);
/**
* 根据用户ID和状态查询日记列表
*/
List<DiaryPost> getByUserIdAndStatus(String userId, String status);
/**
* 根据心情状态查询日记列表
*/
List<DiaryPost> getByMood(String mood);
/**
* 根据标签查询日记列表
*/
List<DiaryPost> getByTags(List<String> tags);
/**
* 根据地点查询日记列表
*/
List<DiaryPost> getByLocation(String location);
/**
* 增加浏览数
*/
boolean incrementViewCount(String diaryId);
/**
* 增加点赞数
*/
boolean incrementLikeCount(String diaryId);
/**
* 减少点赞数
*/
boolean decrementLikeCount(String diaryId);
/**
* 增加评论数
*/
boolean incrementCommentCount(String diaryId);
/**
* 减少评论数
*/
boolean decrementCommentCount(String diaryId);
/**
* 增加分享数
*/
boolean incrementShareCount(String diaryId);
/**
* 更新最后评论时间
*/
boolean updateLastCommentTime(String diaryId);
/**
* 设置精选状态
*/
boolean setFeatured(String diaryId, Integer featured);
/**
* 设置置顶优先级
*/
boolean setPriority(String diaryId, Integer priority);
/**
* 统计用户日记数量
*/
Long countByUserId(String userId);
/**
* 统计用户公开日记数量
*/
Long countPublicByUserId(String userId);
/**
* 统计精选日记数量
*/
Long countFeatured();
/**
* 统计指定状态的日记数量
*/
Long countByStatus(String status);
/**
* 创建日记
*/
DiaryPost createDiaryPost(com.emotion.dto.request.DiaryPostCreateRequest request);
/**
* 更新日记
*/
boolean updateDiaryPost(String diaryId, com.emotion.dto.request.DiaryPostUpdateRequest request);
/**
* 删除日记
*/
boolean deleteDiaryPost(String diaryId);
/**
* 软删除日记
*/
boolean softDeleteDiaryPost(String diaryId);
/**
* 恢复日记
*/
boolean restoreDiaryPost(String diaryId);
/**
* 添加AI评论
*/
boolean addAiComment(String diaryId, String aiComment, Object aiEmotionAnalysis,
BigDecimal aiSentimentScore, List<String> aiKeywords, String aiSuggestions);
/**
* 发表日记并生成AI评论
* @param request 日记创建请求
* @return 日记响应对象,包含AI评论
*/
com.emotion.dto.response.DiaryPostResponse publishDiaryWithAiComment(com.emotion.dto.request.DiaryPostCreateRequest request);
}
@@ -672,7 +672,7 @@ public class AiChatServiceImpl implements AiChatService {
/**
* 发送总结消息到Coze AI
*/
private String sendSummaryMessage(String conversationId, String userMessage, String userId) {
public String sendSummaryMessage(String conversationId, String userMessage, String userId) {
log.info("发送总结消息到Coze AI: conversationId={}, userId={}", conversationId, userId);
// 创建API调用记录(总结不需要messageId)
@@ -12,6 +12,8 @@ import com.emotion.exception.CaptchaException;
import com.emotion.exception.TokenException;
import com.emotion.service.AuthService;
import com.emotion.service.UserService;
import com.emotion.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
@@ -37,6 +39,7 @@ import java.util.concurrent.TimeUnit;
* @author emotion-museum
* @date 2025-07-23
*/
@Slf4j
@Service
public class AuthServiceImpl implements AuthService {
@@ -49,6 +52,9 @@ public class AuthServiceImpl implements AuthService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtil jwtUtil;
private static final String CAPTCHA_PREFIX = "captcha:";
private static final String TOKEN_PREFIX = "token:";
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
@@ -105,28 +111,53 @@ public class AuthServiceImpl implements AuthService {
throw new CaptchaException("验证码错误或已过期");
}
// 验证密码确认
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new BusinessException("密码与确认密码不一致");
}
// 检查账号是否已存在
if (userService.getByAccount(request.getAccount()) != null) {
throw new BusinessException("账号已存在");
}
// 检查邮箱是否已存在
// 检查邮箱是否已存在(只有当邮箱不为空时才检查)
if (StringUtils.hasText(request.getEmail()) && userService.getByEmail(request.getEmail()) != null) {
throw new BusinessException("邮箱已被使用");
}
// 检查手机号是否已存在(只有当手机号不为空时才检查)
if (StringUtils.hasText(request.getPhone()) && userService.getByPhone(request.getPhone()) != null) {
throw new BusinessException("手机号已被使用");
}
// 处理用户名:如果为空则使用账号
String username = StringUtils.hasText(request.getUsername()) ? request.getUsername() : request.getAccount();
// 处理邮箱:如果为空字符串则设为null
String email = StringUtils.hasText(request.getEmail()) ? request.getEmail() : null;
// 处理手机号:如果为空字符串则设为null
String phone = StringUtils.hasText(request.getPhone()) ? request.getPhone() : null;
// 创建用户(密码在UserService中加密,这里不需要预先加密)
User user = userService.createUser(
request.getAccount(),
StringUtils.hasText(request.getUsername()) ? request.getUsername() : request.getAccount(),
username,
request.getPassword(),
request.getEmail(),
request.getPhone()
email,
phone
);
// 设置昵称
// 设置昵称(如果昵称为空则使用用户名)
if (StringUtils.hasText(request.getNickname())) {
user.setNickname(request.getNickname());
} else if (StringUtils.hasText(username)) {
user.setNickname(username);
}
// 如果有昵称变更,更新用户信息
if (StringUtils.hasText(user.getNickname())) {
userService.updateById(user);
}
@@ -267,7 +298,18 @@ public class AuthServiceImpl implements AuthService {
if (!StringUtils.hasText(token)) {
return false;
}
return redisTemplate.hasKey(TOKEN_PREFIX + token);
try {
// 首先尝试JWT验证
if (jwtUtil.validateToken(token)) {
// JWT验证成功,再检查Redis中是否存在(用于登出功能)
return redisTemplate.hasKey(TOKEN_PREFIX + token);
}
return false;
} catch (Exception e) {
log.warn("Token验证失败: {}", e.getMessage());
return false;
}
}
@Override
@@ -275,24 +317,46 @@ public class AuthServiceImpl implements AuthService {
if (!StringUtils.hasText(token)) {
return null;
}
return (String) redisTemplate.opsForValue().get(TOKEN_PREFIX + token);
try {
// 首先尝试从JWT中获取用户ID
String userId = jwtUtil.getUserIdFromToken(token);
if (userId != null) {
// 验证Redis中是否存在该token(确保未被登出)
if (redisTemplate.hasKey(TOKEN_PREFIX + token)) {
return userId;
}
}
return null;
} catch (Exception e) {
log.warn("从Token获取用户ID失败: {}", e.getMessage());
return null;
}
}
@Override
public String getUsernameFromToken(String token) {
String userId = getUserIdFromToken(token);
if (userId == null) {
if (!StringUtils.hasText(token)) {
return null;
}
try {
// 直接从JWT中获取用户名
return jwtUtil.getUsernameFromToken(token);
} catch (Exception e) {
log.warn("从Token获取用户名失败: {}", e.getMessage());
return null;
}
User user = userService.getById(userId);
return user != null ? user.getUsername() : null;
}
/**
* 生成访问令牌
*/
private String generateAccessToken(User user) {
String token = UUID.randomUUID().toString().replace("-", "");
// 使用JWT生成token
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
// 同时在Redis中存储token信息,用于快速验证和登出功能
redisTemplate.opsForValue().set(
TOKEN_PREFIX + token,
user.getId(),
@@ -306,7 +370,10 @@ public class AuthServiceImpl implements AuthService {
* 生成刷新令牌
*/
private String generateRefreshToken(User user) {
String refreshToken = UUID.randomUUID().toString().replace("-", "");
// 使用JWT生成刷新token
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
// 在Redis中存储刷新token信息
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + refreshToken,
user.getId(),
@@ -404,4 +471,31 @@ public class AuthServiceImpl implements AuthService {
}
return response;
}
@Override
public boolean existsByAccount(String account) {
if (!StringUtils.hasText(account)) {
return false;
}
User user = userService.getByAccount(account);
return user != null;
}
@Override
public boolean existsByEmail(String email) {
if (!StringUtils.hasText(email)) {
return false;
}
User user = userService.getByEmail(email);
return user != null;
}
@Override
public boolean existsByPhone(String phone) {
if (!StringUtils.hasText(phone)) {
return false;
}
User user = userService.getByPhone(phone);
return user != null;
}
}
@@ -0,0 +1,290 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.BasePageRequest;
import com.emotion.entity.DiaryComment;
import com.emotion.mapper.DiaryCommentMapper;
import com.emotion.service.DiaryCommentService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 日记评论服务实现类
*
* @author emotion-museum
* @date 2025-07-23
*/
@Service
public class DiaryCommentServiceImpl extends ServiceImpl<DiaryCommentMapper, DiaryComment> implements DiaryCommentService {
@Autowired
private ObjectMapper objectMapper;
@Override
public IPage<DiaryComment> getPage(BasePageRequest request) {
Page<DiaryComment> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getIsTop)
.orderByDesc(DiaryComment::getPublishTime);
return this.page(page, wrapper);
}
@Override
public IPage<DiaryComment> getPageByDiaryId(String diaryId, BasePageRequest request) {
Page<DiaryComment> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getDiaryId, diaryId)
.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getIsTop)
.orderByDesc(DiaryComment::getPublishTime);
return this.page(page, wrapper);
}
@Override
public IPage<DiaryComment> getPageByUserId(String userId, BasePageRequest request) {
Page<DiaryComment> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getUserId, userId)
.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getPublishTime);
return this.page(page, wrapper);
}
@Override
public List<DiaryComment> getRepliesByParentId(String parentCommentId) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getParentCommentId, parentCommentId)
.eq(DiaryComment::getIsDeleted, 0)
.orderByAsc(DiaryComment::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryComment> getByCommentType(String commentType) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getCommentType, commentType)
.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryComment> getByDiaryIdAndCommentType(String diaryId, String commentType) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getDiaryId, diaryId)
.eq(DiaryComment::getCommentType, commentType)
.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getPublishTime);
return this.list(wrapper);
}
@Override
public boolean incrementLikeCount(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.setSql("like_count = like_count + 1");
return this.update(wrapper);
}
@Override
public boolean decrementLikeCount(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.setSql("like_count = GREATEST(like_count - 1, 0)");
return this.update(wrapper);
}
@Override
public boolean incrementReplyCount(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.setSql("reply_count = reply_count + 1");
return this.update(wrapper);
}
@Override
public boolean decrementReplyCount(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.setSql("reply_count = GREATEST(reply_count - 1, 0)");
return this.update(wrapper);
}
@Override
public boolean updateLastReplyTime(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.set(DiaryComment::getLastReplyTime, LocalDateTime.now());
return this.update(wrapper);
}
@Override
public boolean setTop(String commentId, Integer isTop) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.set(DiaryComment::getIsTop, isTop);
return this.update(wrapper);
}
@Override
public Long countByDiaryId(String diaryId) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getDiaryId, diaryId)
.eq(DiaryComment::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countByUserId(String userId) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getUserId, userId)
.eq(DiaryComment::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countByCommentType(String commentType) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getCommentType, commentType)
.eq(DiaryComment::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countReplies(String parentCommentId) {
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getParentCommentId, parentCommentId)
.eq(DiaryComment::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public DiaryComment createComment(String diaryId, String userId, String content, List<String> images,
String parentCommentId, Integer isAnonymous) {
DiaryComment comment = DiaryComment.builder()
.diaryId(diaryId)
.userId(userId)
.content(content)
.images(convertListToJson(images))
.parentCommentId(parentCommentId)
.commentType("user")
.likeCount(0)
.replyCount(0)
.isAnonymous(isAnonymous)
.isTop(0)
.status("published")
.publishTime(LocalDateTime.now())
.build();
this.save(comment);
// 如果有父评论,更新父评论的回复数
if (StringUtils.hasText(parentCommentId)) {
this.incrementReplyCount(parentCommentId);
this.updateLastReplyTime(parentCommentId);
}
return comment;
}
@Override
public DiaryComment createAiComment(String diaryId, String content, String aiCommentSource,
BigDecimal emotionScore, BigDecimal sentimentScore) {
DiaryComment comment = DiaryComment.builder()
.diaryId(diaryId)
.userId("system") // AI评论使用system用户ID
.content(content)
.commentType("ai")
.aiCommentSource(aiCommentSource)
.likeCount(0)
.replyCount(0)
.isAnonymous(0)
.isTop(0)
.status("published")
.publishTime(LocalDateTime.now())
.emotionScore(emotionScore)
.sentimentScore(sentimentScore)
.build();
this.save(comment);
return comment;
}
@Override
public boolean updateComment(String commentId, String content, List<String> images) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.set(StringUtils.hasText(content), DiaryComment::getContent, content)
.set(images != null, DiaryComment::getImages, convertListToJson(images));
return this.update(wrapper);
}
@Override
public boolean deleteComment(String commentId) {
return this.removeById(commentId);
}
@Override
public boolean softDeleteComment(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.set(DiaryComment::getIsDeleted, 1);
return this.update(wrapper);
}
@Override
public boolean restoreComment(String commentId) {
LambdaUpdateWrapper<DiaryComment> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryComment::getId, commentId)
.set(DiaryComment::getIsDeleted, 0);
return this.update(wrapper);
}
@Override
public List<DiaryComment> getCommentTree(String diaryId) {
// 获取所有顶级评论(没有父评论的评论)
LambdaQueryWrapper<DiaryComment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryComment::getDiaryId, diaryId)
.isNull(DiaryComment::getParentCommentId)
.eq(DiaryComment::getIsDeleted, 0)
.orderByDesc(DiaryComment::getIsTop)
.orderByDesc(DiaryComment::getPublishTime);
List<DiaryComment> topComments = this.list(wrapper);
// 为每个顶级评论加载回复
return topComments.stream()
.peek(comment -> {
List<DiaryComment> replies = getRepliesByParentId(comment.getId());
// 这里可以递归加载更深层的回复,但为了性能考虑,通常只加载一层
})
.collect(Collectors.toList());
}
/**
* 将List转换为JSON字符串
*/
private String convertListToJson(List<?> list) {
if (list == null || list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(list);
} catch (JsonProcessingException e) {
return null;
}
}
}
@@ -0,0 +1,446 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.BasePageRequest;
import com.emotion.entity.DiaryPost;
import com.emotion.mapper.DiaryPostMapper;
import com.emotion.service.DiaryPostService;
import com.emotion.service.DiaryCommentService;
import com.emotion.service.AiChatService;
import com.emotion.dto.request.DiaryPostCreateRequest;
import com.emotion.dto.response.DiaryPostResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户日记服务实现类
*
* @author emotion-museum
* @date 2025-07-23
*/
@Service
public class DiaryPostServiceImpl extends ServiceImpl<DiaryPostMapper, DiaryPost> implements DiaryPostService {
@Autowired
private AiChatService aiChatService;
@Autowired
private DiaryCommentService diaryCommentService;
@Autowired
private ObjectMapper objectMapper;
@Override
public IPage<DiaryPost> getPage(BasePageRequest request) {
Page<DiaryPost> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPriority)
.orderByDesc(DiaryPost::getPublishTime);
return this.page(page, wrapper);
}
@Override
public IPage<DiaryPost> getPageByUserId(String userId, BasePageRequest request) {
Page<DiaryPost> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getUserId, userId)
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPriority)
.orderByDesc(DiaryPost::getPublishTime);
return this.page(page, wrapper);
}
@Override
public IPage<DiaryPost> getPublicPageByUserId(String userId, BasePageRequest request) {
Page<DiaryPost> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getUserId, userId)
.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPriority)
.orderByDesc(DiaryPost::getPublishTime);
return this.page(page, wrapper);
}
@Override
public IPage<DiaryPost> getFeaturedPage(BasePageRequest request) {
Page<DiaryPost> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getFeatured, 1)
.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPriority)
.orderByDesc(DiaryPost::getPublishTime);
return this.page(page, wrapper);
}
@Override
public List<DiaryPost> getByStatus(String status) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getStatus, status)
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryPost> getByUserIdAndStatus(String userId, String status) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getUserId, userId)
.eq(DiaryPost::getStatus, status)
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryPost> getByMood(String mood) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getMood, mood)
.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryPost> getByTags(List<String> tags) {
// 这里需要根据实际需求实现标签查询逻辑
// 由于tags字段是JSON格式,可能需要使用数据库的JSON查询功能
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPublishTime);
return this.list(wrapper);
}
@Override
public List<DiaryPost> getByLocation(String location) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.like(DiaryPost::getLocation, location)
.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0)
.orderByDesc(DiaryPost::getPublishTime);
return this.list(wrapper);
}
@Override
public boolean incrementViewCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("view_count = view_count + 1");
return this.update(wrapper);
}
@Override
public boolean incrementLikeCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("like_count = like_count + 1");
return this.update(wrapper);
}
@Override
public boolean decrementLikeCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("like_count = GREATEST(like_count - 1, 0)");
return this.update(wrapper);
}
@Override
public boolean incrementCommentCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("comment_count = comment_count + 1");
return this.update(wrapper);
}
@Override
public boolean decrementCommentCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("comment_count = GREATEST(comment_count - 1, 0)");
return this.update(wrapper);
}
@Override
public boolean incrementShareCount(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.setSql("share_count = share_count + 1");
return this.update(wrapper);
}
@Override
public boolean updateLastCommentTime(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getLastCommentTime, LocalDateTime.now());
return this.update(wrapper);
}
@Override
public boolean setFeatured(String diaryId, Integer featured) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getFeatured, featured);
return this.update(wrapper);
}
@Override
public boolean setPriority(String diaryId, Integer priority) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getPriority, priority);
return this.update(wrapper);
}
@Override
public Long countByUserId(String userId) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getUserId, userId)
.eq(DiaryPost::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countPublicByUserId(String userId) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getUserId, userId)
.eq(DiaryPost::getIsPublic, 1)
.eq(DiaryPost::getStatus, "published")
.eq(DiaryPost::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countFeatured() {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getFeatured, 1)
.eq(DiaryPost::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public Long countByStatus(String status) {
LambdaQueryWrapper<DiaryPost> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DiaryPost::getStatus, status)
.eq(DiaryPost::getIsDeleted, 0);
return this.count(wrapper);
}
@Override
public DiaryPost createDiaryPost(com.emotion.dto.request.DiaryPostCreateRequest request) {
// 处理标题:如果为空,则设置为null或生成默认标题
String title = request.getTitle();
if (title == null || title.trim().isEmpty()) {
// 可以选择设置为null,或者生成一个默认标题
// 这里我们设置为null,让数据库使用默认值
title = null;
}
DiaryPost diaryPost = DiaryPost.builder()
.userId(request.getUserId())
.title(title)
.content(request.getContent())
.images(convertListToJson(request.getImages()))
.videos(convertListToJson(request.getVideos()))
.location(request.getLocation())
.weather(request.getWeather())
.mood(request.getMood())
.tags(convertListToJson(request.getTags()))
.isPublic(request.getIsPublic())
.isAnonymous(request.getIsAnonymous())
.viewCount(0)
.likeCount(0)
.commentCount(0)
.shareCount(0)
.publishTime(LocalDateTime.now())
.status("published")
.priority(0)
.featured(0)
.build();
this.save(diaryPost);
return diaryPost;
}
@Override
public boolean updateDiaryPost(String diaryId, com.emotion.dto.request.DiaryPostUpdateRequest request) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(StringUtils.hasText(request.getTitle()), DiaryPost::getTitle, request.getTitle())
.set(StringUtils.hasText(request.getContent()), DiaryPost::getContent, request.getContent())
.set(request.getImages() != null, DiaryPost::getImages, convertListToJson(request.getImages()))
.set(request.getVideos() != null, DiaryPost::getVideos, convertListToJson(request.getVideos()))
.set(StringUtils.hasText(request.getLocation()), DiaryPost::getLocation, request.getLocation())
.set(StringUtils.hasText(request.getWeather()), DiaryPost::getWeather, request.getWeather())
.set(StringUtils.hasText(request.getMood()), DiaryPost::getMood, request.getMood())
.set(request.getTags() != null, DiaryPost::getTags, convertListToJson(request.getTags()))
.set(request.getIsPublic() != null, DiaryPost::getIsPublic, request.getIsPublic())
.set(request.getIsAnonymous() != null, DiaryPost::getIsAnonymous, request.getIsAnonymous())
.set(StringUtils.hasText(request.getStatus()), DiaryPost::getStatus, request.getStatus());
return this.update(wrapper);
}
@Override
public boolean deleteDiaryPost(String diaryId) {
return this.removeById(diaryId);
}
@Override
public boolean softDeleteDiaryPost(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getIsDeleted, 1);
return this.update(wrapper);
}
@Override
public boolean restoreDiaryPost(String diaryId) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getIsDeleted, 0);
return this.update(wrapper);
}
@Override
public boolean addAiComment(String diaryId, String aiComment, Object aiEmotionAnalysis,
BigDecimal aiSentimentScore, List<String> aiKeywords, String aiSuggestions) {
LambdaUpdateWrapper<DiaryPost> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(DiaryPost::getId, diaryId)
.set(DiaryPost::getAiComment, aiComment)
.set(DiaryPost::getAiCommentTime, LocalDateTime.now())
.set(DiaryPost::getAiEmotionAnalysis, convertObjectToJson(aiEmotionAnalysis))
.set(DiaryPost::getAiSentimentScore, aiSentimentScore)
.set(DiaryPost::getAiKeywords, convertListToJson(aiKeywords))
.set(DiaryPost::getAiSuggestions, aiSuggestions);
return this.update(wrapper);
}
@Override
public com.emotion.dto.response.DiaryPostResponse publishDiaryWithAiComment(com.emotion.dto.request.DiaryPostCreateRequest request) {
// 1. 保存日记
DiaryPost diaryPost = createDiaryPost(request);
// 2. 生成AI评论
String aiComment = null;
try {
String conversationId = "diary-" + diaryPost.getId();
aiComment = aiChatService.sendSummaryMessage(conversationId, diaryPost.getContent(), request.getUserId());
} catch (Exception e) {
aiComment = "开开:AI评论生成失败";
}
// 3. 写入AI评论到diary_comment表
if (aiComment != null) {
diaryCommentService.createAiComment(
diaryPost.getId(),
aiComment,
"diary_ai_summary",
null,
null
);
addAiComment(
diaryPost.getId(),
aiComment,
null,
null,
null,
null
);
}
// 4. 返回日记详情(含AI评论)
com.emotion.dto.response.DiaryPostResponse response = new com.emotion.dto.response.DiaryPostResponse();
org.springframework.beans.BeanUtils.copyProperties(diaryPost, response);
// 查询AI评论
List<com.emotion.entity.DiaryComment> aiComments = diaryCommentService.getByDiaryIdAndCommentType(diaryPost.getId(), "ai");
if (!aiComments.isEmpty()) {
response.setAiComment(aiComments.get(0).getContent());
}
// 转换时间格式
if (diaryPost.getPublishTime() != null) {
response.setPublishTime(diaryPost.getPublishTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (diaryPost.getLastCommentTime() != null) {
response.setLastCommentTime(diaryPost.getLastCommentTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (diaryPost.getAiCommentTime() != null) {
response.setAiCommentTime(diaryPost.getAiCommentTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (diaryPost.getCreateTime() != null) {
response.setCreateTime(diaryPost.getCreateTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (diaryPost.getUpdateTime() != null) {
response.setUpdateTime(diaryPost.getUpdateTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
// 转换JSON字段
try {
if (diaryPost.getImages() != null) {
response.setImages(objectMapper.readValue(diaryPost.getImages(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}));
}
if (diaryPost.getVideos() != null) {
response.setVideos(objectMapper.readValue(diaryPost.getVideos(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}));
}
if (diaryPost.getTags() != null) {
response.setTags(objectMapper.readValue(diaryPost.getTags(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}));
}
if (diaryPost.getAiKeywords() != null) {
response.setAiKeywords(objectMapper.readValue(diaryPost.getAiKeywords(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}));
}
if (diaryPost.getAiEmotionAnalysis() != null) {
response.setAiEmotionAnalysis(objectMapper.readValue(diaryPost.getAiEmotionAnalysis(), Object.class));
}
if (diaryPost.getMetadata() != null) {
response.setMetadata(objectMapper.readValue(diaryPost.getMetadata(), Object.class));
}
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
// 忽略JSON解析错误
}
return response;
}
/**
* 将List转换为JSON字符串
*/
private String convertListToJson(List<?> list) {
if (list == null || list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(list);
} catch (JsonProcessingException e) {
return null;
}
}
/**
* 将Object转换为JSON字符串
*/
private String convertObjectToJson(Object obj) {
if (obj == null) {
return null;
}
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
return null;
}
}
}
@@ -262,26 +262,8 @@ public class WebSocketServiceImpl implements WebSocketService {
userId
);
// 构建AI回复消息(不分割,保持完整性)
WebSocketMessage aiMessage = WebSocketMessage.builder()
.messageId(UUID.randomUUID().toString())
.conversationId(conversationId)
.type(WebSocketMessage.MessageType.TEXT)
.content(aiReply)
.senderId("ai")
.senderType(WebSocketMessage.SenderType.AI)
.status(WebSocketMessage.MessageStatus.SENT)
.createTime(LocalDateTime.now())
.build();
// AI回复已经在sendChatMessageForWebSocket中保存了,这里不需要重复保存
// 发送AI回复
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", aiMessage);
if (conversationId != null) {
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, aiMessage);
}
// 根据换行符分割AI回复并按顺序发送多条消息
sendAiReplyInParts(userId, conversationId, aiReply);
// 更新会话的最后活跃时间和消息数量
updateConversationActivity(conversationId);
@@ -398,4 +380,158 @@ public class WebSocketServiceImpl implements WebSocketService {
log.error("更新会话活跃状态失败: conversationId={}", conversationId, e);
}
}
/**
* 根据换行符分割AI回复并按顺序发送多条消息
*
* @param userId 用户ID
* @param conversationId 会话ID
* @param aiReply AI回复内容
*/
private void sendAiReplyInParts(String userId, String conversationId, String aiReply) {
try {
log.info("开始处理AI回复消息: userId={}, conversationId={}, aiReply长度={}",
userId, conversationId, aiReply != null ? aiReply.length() : 0);
if (aiReply == null || aiReply.trim().isEmpty()) {
log.warn("AI回复内容为空,跳过发送");
return;
}
// 检查是否需要分割
boolean needsSplit = aiReply.contains("\n\n") || aiReply.contains("\n");
if (!needsSplit) {
// 不需要分割,直接发送完整消息
log.info("AI回复无换行符,发送完整消息");
sendSingleAiMessage(userId, conversationId, aiReply.trim());
return;
}
// 需要分割,按换行符分割并发送多条消息
log.info("AI回复包含换行符,开始分割发送");
String[] replyParts = splitAiReply(aiReply);
log.info("AI回复分割完成,共{}个部分", replyParts.length);
// 按顺序发送每个部分
int sentCount = 0;
for (int i = 0; i < replyParts.length; i++) {
String part = replyParts[i].trim();
// 跳过空白部分
if (part.isEmpty()) {
continue;
}
// 发送消息部分
sendSingleAiMessage(userId, conversationId, part);
sentCount++;
log.info("发送AI回复部分 {}/{}: 内容长度={}", sentCount, replyParts.length, part.length());
// 在多个部分之间添加短暂延迟,模拟自然对话节奏
if (i < replyParts.length - 1) {
try {
Thread.sleep(500); // 延迟500毫秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("发送AI回复时被中断");
break;
}
}
}
log.info("AI回复发送完成: userId={}, conversationId={}, 实际发送{}条消息",
userId, conversationId, sentCount);
} catch (Exception e) {
log.error("分割发送AI回复失败: userId={}, conversationId={}", userId, conversationId, e);
// 发送错误时,尝试发送完整的原始回复
try {
WebSocketMessage fallbackMessage = WebSocketMessage.builder()
.messageId(UUID.randomUUID().toString())
.conversationId(conversationId)
.type(WebSocketMessage.MessageType.TEXT)
.content(aiReply)
.senderId("ai")
.senderType(WebSocketMessage.SenderType.AI)
.status(WebSocketMessage.MessageStatus.SENT)
.createTime(LocalDateTime.now())
.build();
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", fallbackMessage);
if (conversationId != null) {
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, fallbackMessage);
}
log.info("已发送完整AI回复作为备用方案");
} catch (Exception fallbackError) {
log.error("发送备用AI回复也失败", fallbackError);
sendErrorMessage(userId, "AI回复发送失败,请稍后重试");
}
}
}
/**
* 发送单条AI消息
*
* @param userId 用户ID
* @param conversationId 会话ID
* @param content 消息内容
*/
private void sendSingleAiMessage(String userId, String conversationId, String content) {
// 构建AI回复消息
WebSocketMessage aiMessage = WebSocketMessage.builder()
.messageId(UUID.randomUUID().toString())
.conversationId(conversationId)
.type(WebSocketMessage.MessageType.TEXT)
.content(content)
.senderId("ai")
.senderType(WebSocketMessage.SenderType.AI)
.status(WebSocketMessage.MessageStatus.SENT)
.createTime(LocalDateTime.now())
.build();
// 发送给用户私有队列
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", aiMessage);
// 发送到会话公共频道
if (conversationId != null) {
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, aiMessage);
}
}
/**
* 智能分割AI回复内容
*
* @param aiReply AI回复内容
* @return 分割后的内容数组
*/
private String[] splitAiReply(String aiReply) {
if (aiReply == null || aiReply.trim().isEmpty()) {
return new String[0];
}
// 首先尝试按双换行符分割(段落分割)
if (aiReply.contains("\n\n")) {
String[] parts = aiReply.split("\n\n");
log.debug("按双换行符分割,得到{}个部分", parts.length);
return parts;
}
// 如果没有双换行符,按单换行符分割(行分割)
if (aiReply.contains("\n")) {
String[] parts = aiReply.split("\n");
log.debug("按单换行符分割,得到{}个部分", parts.length);
return parts;
}
// 如果没有换行符,返回原始内容(这种情况不应该到达这里)
log.debug("没有换行符,返回原始内容");
return new String[]{aiReply};
}
}
@@ -473,7 +473,122 @@ CREATE TABLE reward (
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '奖励表';
-- ============================================================================
-- 14. 访客用户表 (guest_user)
-- 14. 用户统计表 (user_stats)
-- ============================================================================
CREATE TABLE user_stats (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键', -- UUID主键
user_id VARCHAR(64) UNIQUE COMMENT '用户ID', -- 用户ID
total_conversations INT DEFAULT 0 COMMENT '总对话数', -- 总对话数
total_messages INT DEFAULT 0 COMMENT '总消息数', -- 总消息数
total_emotions_recorded INT DEFAULT 0 COMMENT '总情绪记录数', -- 总情绪记录数
topics_completed INT DEFAULT 0 COMMENT '完成的课题数', -- 完成的课题数
achievements_unlocked INT DEFAULT 0 COMMENT '解锁的成就数', -- 解锁的成就数
total_points INT DEFAULT 0 COMMENT '总积分', -- 总积分
consecutive_days INT DEFAULT 0 COMMENT '连续使用天数', -- 连续使用天数
max_consecutive_days INT DEFAULT 0 COMMENT '最大连续天数', -- 最大连续天数
locations_visited INT DEFAULT 0 COMMENT '访问的地点数', -- 访问的地点数
posts_created INT DEFAULT 0 COMMENT '创建的帖子数', -- 创建的帖子数
comments_made INT DEFAULT 0 COMMENT '评论数', -- 评论数
likes_received INT DEFAULT 0 COMMENT '收到的点赞数', -- 收到的点赞数
social_interactions INT DEFAULT 0 COMMENT '社交互动数', -- 社交互动数
-- 日记相关统计
diary_posts_created INT DEFAULT 0 COMMENT '创建的日记数', -- 创建的日记数
diary_likes_received INT DEFAULT 0 COMMENT '日记收到的点赞数', -- 日记收到的点赞数
diary_comments_received INT DEFAULT 0 COMMENT '日记收到的评论数', -- 日记收到的评论数
diary_views_received INT DEFAULT 0 COMMENT '日记收到的浏览数', -- 日记收到的浏览数
diary_shares_received INT DEFAULT 0 COMMENT '日记收到的分享数', -- 日记收到的分享数
diary_comments_made INT DEFAULT 0 COMMENT '发表的日记评论数', -- 发表的日记评论数
diary_likes_given INT DEFAULT 0 COMMENT '给他人日记的点赞数', -- 给他人日记的点赞数
featured_diary_count INT DEFAULT 0 COMMENT '精选日记数量', -- 精选日记数量
ai_comments_received INT DEFAULT 0 COMMENT '收到的AI评论数', -- 收到的AI评论数
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID', -- 创建人ID
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间
update_by VARCHAR(64) COMMENT '更新人ID', -- 更新人ID
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', -- 更新时间
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除', -- 是否删除: 0-未删除, 1-已删除
remarks VARCHAR(500) COMMENT '备注' -- 备注
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户统计表';
-- ============================================================================
-- 16. 用户日记表 (diary_post) - 类似朋友圈功能
-- 关联说明: user_id 关联 user.id,通过代码逻辑维护关联关系
-- ============================================================================
CREATE TABLE diary_post (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键', -- UUID主键
user_id VARCHAR(64) COMMENT '用户ID (关联user.id)', -- 用户ID (关联user.id)
title VARCHAR(200) COMMENT '日记标题', -- 日记标题
content TEXT COMMENT '日记内容', -- 日记内容
images JSON COMMENT '图片列表 (存储图片URL数组)', -- 图片列表 (存储图片URL数组)
videos JSON COMMENT '视频列表 (存储视频URL数组)', -- 视频列表 (存储视频URL数组)
location VARCHAR(200) COMMENT '发布地点', -- 发布地点
latitude DECIMAL(10, 8) COMMENT '纬度', -- 纬度
longitude DECIMAL(11, 8) COMMENT '经度', -- 经度
weather VARCHAR(50) COMMENT '天气信息', -- 天气信息
mood VARCHAR(50) COMMENT '心情状态', -- 心情状态
mood_score DECIMAL(3, 2) COMMENT '心情评分 (0-10)', -- 心情评分 (0-10)
tags JSON COMMENT '标签列表', -- 标签列表
is_public TINYINT DEFAULT 1 COMMENT '是否公开: 0-仅自己可见, 1-公开', -- 是否公开: 0-仅自己可见, 1-公开
is_anonymous TINYINT DEFAULT 0 COMMENT '是否匿名发布: 0-实名, 1-匿名', -- 是否匿名发布: 0-实名, 1-匿名
view_count INT DEFAULT 0 COMMENT '浏览数', -- 浏览数
like_count INT DEFAULT 0 COMMENT '点赞数', -- 点赞数
comment_count INT DEFAULT 0 COMMENT '评论数', -- 评论数
share_count INT DEFAULT 0 COMMENT '分享数', -- 分享数
ai_comment TEXT COMMENT 'AI评论内容', -- AI评论内容
ai_comment_time DATETIME COMMENT 'AI评论时间', -- AI评论时间
ai_emotion_analysis JSON COMMENT 'AI情绪分析结果', -- AI情绪分析结果
ai_sentiment_score DECIMAL(3, 2) COMMENT 'AI情感评分 (-1到1)', -- AI情感评分 (-1到1)
ai_keywords JSON COMMENT 'AI提取的关键词', -- AI提取的关键词
ai_suggestions TEXT COMMENT 'AI建议', -- AI建议
publish_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间', -- 发布时间
last_comment_time DATETIME COMMENT '最后评论时间', -- 最后评论时间
status VARCHAR(20) DEFAULT 'published' COMMENT '状态: draft-草稿, published-已发布, hidden-隐藏, deleted-已删除', -- 状态: draft-草稿, published-已发布, hidden-隐藏, deleted-已删除
priority INT DEFAULT 0 COMMENT '优先级 (用于置顶功能)', -- 优先级 (用于置顶功能)
featured TINYINT DEFAULT 0 COMMENT '是否精选: 0-普通, 1-精选', -- 是否精选: 0-普通, 1-精选
metadata JSON COMMENT '扩展元数据', -- 扩展元数据
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID', -- 创建人ID
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间
update_by VARCHAR(64) COMMENT '更新人ID', -- 更新人ID
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', -- 更新时间
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除', -- 是否删除: 0-未删除, 1-已删除
remarks VARCHAR(500) COMMENT '备注' -- 备注
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户日记表 - 类似朋友圈功能';
-- ============================================================================
-- 17. 日记评论表 (diary_comment)
-- 关联说明: diary_id 关联 diary_post.iduser_id 关联 user.id,通过代码逻辑维护关联关系
-- ============================================================================
CREATE TABLE diary_comment (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键', -- UUID主键
diary_id VARCHAR(64) COMMENT '日记ID (关联diary_post.id)', -- 日记ID (关联diary_post.id)
user_id VARCHAR(64) COMMENT '评论用户ID (关联user.id)', -- 评论用户ID (关联user.id)
parent_comment_id VARCHAR(64) COMMENT '父评论ID (用于回复功能)', -- 父评论ID (用于回复功能)
content TEXT COMMENT '评论内容', -- 评论内容
images JSON COMMENT '评论图片 (存储图片URL数组)', -- 评论图片 (存储图片URL数组)
comment_type VARCHAR(20) DEFAULT 'user' COMMENT '评论类型: user-用户评论, ai-AI评论, system-系统评论', -- 评论类型: user-用户评论, ai-AI评论, system-系统评论
ai_comment_source VARCHAR(50) COMMENT 'AI评论来源 (如: emotion_analysis, sentiment_analysis)', -- AI评论来源 (如: emotion_analysis, sentiment_analysis)
like_count INT DEFAULT 0 COMMENT '点赞数', -- 点赞数
reply_count INT DEFAULT 0 COMMENT '回复数', -- 回复数
is_anonymous TINYINT DEFAULT 0 COMMENT '是否匿名评论: 0-实名, 1-匿名', -- 是否匿名评论: 0-实名, 1-匿名
is_top TINYINT DEFAULT 0 COMMENT '是否置顶: 0-普通, 1-置顶', -- 是否置顶: 0-普通, 1-置顶
status VARCHAR(20) DEFAULT 'published' COMMENT '状态: published-已发布, hidden-隐藏, deleted-已删除', -- 状态: published-已发布, hidden-隐藏, deleted-已删除
publish_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间', -- 发布时间
last_reply_time DATETIME COMMENT '最后回复时间', -- 最后回复时间
emotion_score DECIMAL(3, 2) COMMENT '情绪评分', -- 情绪评分
sentiment_score DECIMAL(3, 2) COMMENT '情感评分', -- 情感评分
metadata JSON COMMENT '扩展元数据', -- 扩展元数据
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID', -- 创建人ID
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间
update_by VARCHAR(64) COMMENT '更新人ID', -- 更新人ID
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', -- 更新时间
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除', -- 是否删除: 0-未删除, 1-已删除
remarks VARCHAR(500) COMMENT '备注' -- 备注
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '日记评论表';
-- ============================================================================
-- 18. 访客用户表 (guest_user)
-- ============================================================================
CREATE TABLE guest_user (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键', -- UUID主键
@@ -496,34 +611,6 @@ CREATE TABLE guest_user (
remarks VARCHAR(500) COMMENT '备注' -- 备注
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '访客用户表';
-- ============================================================================
-- 15. 用户统计表 (user_stats)
-- ============================================================================
CREATE TABLE user_stats (
id VARCHAR(64) PRIMARY KEY COMMENT 'UUID主键', -- UUID主键
user_id VARCHAR(64) UNIQUE COMMENT '用户ID', -- 用户ID
total_conversations INT DEFAULT 0 COMMENT '总对话数', -- 总对话数
total_messages INT DEFAULT 0 COMMENT '总消息数', -- 总消息数
total_emotions_recorded INT DEFAULT 0 COMMENT '总情绪记录数', -- 总情绪记录数
topics_completed INT DEFAULT 0 COMMENT '完成的课题数', -- 完成的课题数
achievements_unlocked INT DEFAULT 0 COMMENT '解锁的成就数', -- 解锁的成就数
total_points INT DEFAULT 0 COMMENT '总积分', -- 总积分
consecutive_days INT DEFAULT 0 COMMENT '连续使用天数', -- 连续使用天数
max_consecutive_days INT DEFAULT 0 COMMENT '最大连续天数', -- 最大连续天数
locations_visited INT DEFAULT 0 COMMENT '访问的地点数', -- 访问的地点数
posts_created INT DEFAULT 0 COMMENT '创建的帖子数', -- 创建的帖子数
comments_made INT DEFAULT 0 COMMENT '评论数', -- 评论数
likes_received INT DEFAULT 0 COMMENT '收到的点赞数', -- 收到的点赞数
social_interactions INT DEFAULT 0 COMMENT '社交互动数', -- 社交互动数
-- 公共字段
create_by VARCHAR(64) COMMENT '创建人ID', -- 创建人ID
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间
update_by VARCHAR(64) COMMENT '更新人ID', -- 更新人ID
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', -- 更新时间
is_deleted TINYINT DEFAULT 0 COMMENT '是否删除: 0-未删除, 1-已删除', -- 是否删除: 0-未删除, 1-已删除
remarks VARCHAR(500) COMMENT '备注' -- 备注
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户统计表';
-- ============================================================================
-- 创建索引以提高查询性能
-- 注意: MySQL的CREATE INDEX不支持IF NOT EXISTS
@@ -813,6 +900,105 @@ CREATE INDEX idx_user_stats_update_time ON user_stats (update_time);
CREATE INDEX idx_user_stats_create_time ON user_stats (create_time);
-- user_stats表日记相关索引
CREATE INDEX idx_user_stats_diary_posts_created ON user_stats (diary_posts_created);
CREATE INDEX idx_user_stats_diary_likes_received ON user_stats (diary_likes_received);
CREATE INDEX idx_user_stats_diary_comments_received ON user_stats (diary_comments_received);
CREATE INDEX idx_user_stats_diary_views_received ON user_stats (diary_views_received);
CREATE INDEX idx_user_stats_featured_diary_count ON user_stats (featured_diary_count);
CREATE INDEX idx_user_stats_ai_comments_received ON user_stats (ai_comments_received);
-- diary_post表索引
CREATE INDEX idx_diary_post_user_id ON diary_post (user_id);
CREATE INDEX idx_diary_post_publish_time ON diary_post (publish_time);
CREATE INDEX idx_diary_post_last_comment_time ON diary_post (last_comment_time);
CREATE INDEX idx_diary_post_status ON diary_post (status);
CREATE INDEX idx_diary_post_priority ON diary_post (priority);
CREATE INDEX idx_diary_post_featured ON diary_post (featured);
CREATE INDEX idx_diary_post_create_time ON diary_post (create_time);
-- diary_comment表索引
CREATE INDEX idx_diary_comment_diary_id ON diary_comment (diary_id);
CREATE INDEX idx_diary_comment_user_id ON diary_comment (user_id);
CREATE INDEX idx_diary_comment_parent_comment_id ON diary_comment (parent_comment_id);
CREATE INDEX idx_diary_comment_publish_time ON diary_comment (publish_time);
CREATE INDEX idx_diary_comment_last_reply_time ON diary_comment (last_reply_time);
CREATE INDEX idx_diary_comment_emotion_score ON diary_comment (emotion_score);
CREATE INDEX idx_diary_comment_sentiment_score ON diary_comment (sentiment_score);
CREATE INDEX idx_diary_comment_create_time ON diary_comment (create_time);
-- diary_post表复合索引和功能索引
CREATE INDEX idx_diary_post_user_publish ON diary_post (user_id, publish_time);
CREATE INDEX idx_diary_post_user_status ON diary_post (user_id, status);
CREATE INDEX idx_diary_post_public_publish ON diary_post (is_public, publish_time);
CREATE INDEX idx_diary_post_featured_publish ON diary_post (featured, publish_time);
CREATE INDEX idx_diary_post_mood_score ON diary_post (mood_score);
CREATE INDEX idx_diary_post_ai_sentiment ON diary_post (ai_sentiment_score);
CREATE INDEX idx_diary_post_location ON diary_post (latitude, longitude);
CREATE INDEX idx_diary_post_like_count ON diary_post (like_count);
CREATE INDEX idx_diary_post_comment_count ON diary_post (comment_count);
CREATE INDEX idx_diary_post_view_count ON diary_post (view_count);
CREATE INDEX idx_diary_post_create_by ON diary_post (create_by);
CREATE INDEX idx_diary_post_update_by ON diary_post (update_by);
CREATE INDEX idx_diary_post_is_deleted ON diary_post (is_deleted);
-- diary_comment表复合索引和功能索引
CREATE INDEX idx_diary_comment_diary_publish ON diary_comment (diary_id, publish_time);
CREATE INDEX idx_diary_comment_diary_type ON diary_comment (diary_id, comment_type);
CREATE INDEX idx_diary_comment_user_publish ON diary_comment (user_id, publish_time);
CREATE INDEX idx_diary_comment_parent_publish ON diary_comment (parent_comment_id, publish_time);
CREATE INDEX idx_diary_comment_type_publish ON diary_comment (comment_type, publish_time);
CREATE INDEX idx_diary_comment_status ON diary_comment (status);
CREATE INDEX idx_diary_comment_is_top ON diary_comment (is_top);
CREATE INDEX idx_diary_comment_like_count ON diary_comment (like_count);
CREATE INDEX idx_diary_comment_reply_count ON diary_comment (reply_count);
CREATE INDEX idx_diary_comment_ai_source ON diary_comment (ai_comment_source);
CREATE INDEX idx_diary_comment_create_by ON diary_comment (create_by);
CREATE INDEX idx_diary_comment_update_by ON diary_comment (update_by);
CREATE INDEX idx_diary_comment_is_deleted ON diary_comment (is_deleted);
-- guest_user表索引
CREATE INDEX idx_guest_user_guest_user_id ON guest_user (guest_user_id);
@@ -0,0 +1,100 @@
package com.emotion.controller;
import com.emotion.service.AuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* AuthController 测试类
*
* @author emotion-museum
* @date 2025-07-26
*/
@WebMvcTest(AuthController.class)
public class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private AuthService authService;
@Test
public void testCheckAccountExists() throws Exception {
// 模拟账户存在的情况
when(authService.existsByAccount("existingUser")).thenReturn(true);
mockMvc.perform(get("/auth/check-account")
.param("account", "existingUser"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(true));
}
@Test
public void testCheckAccountNotExists() throws Exception {
// 模拟账户不存在的情况
when(authService.existsByAccount("nonExistingUser")).thenReturn(false);
mockMvc.perform(get("/auth/check-account")
.param("account", "nonExistingUser"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(false));
}
@Test
public void testCheckEmailExists() throws Exception {
// 模拟邮箱存在的情况
when(authService.existsByEmail("existing@example.com")).thenReturn(true);
mockMvc.perform(get("/auth/check-email")
.param("email", "existing@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(true));
}
@Test
public void testCheckEmailNotExists() throws Exception {
// 模拟邮箱不存在的情况
when(authService.existsByEmail("nonexisting@example.com")).thenReturn(false);
mockMvc.perform(get("/auth/check-email")
.param("email", "nonexisting@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(false));
}
@Test
public void testCheckPhoneExists() throws Exception {
// 模拟手机号存在的情况
when(authService.existsByPhone("13800138000")).thenReturn(true);
mockMvc.perform(get("/auth/check-phone")
.param("phone", "13800138000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(true));
}
@Test
public void testCheckPhoneNotExists() throws Exception {
// 模拟手机号不存在的情况
when(authService.existsByPhone("13900139000")).thenReturn(false);
mockMvc.perform(get("/auth/check-phone")
.param("phone", "13900139000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(false));
}
}
+727
View File
@@ -0,0 +1,727 @@
# Java代码规范
## 1. 命名规范
### 1.1 包命名
- 包名全部小写,使用点分隔
- 示例:`com.emotionmuseum.user.controller`
### 1.2 类命名
- 使用大驼峰命名法(PascalCase
- 示例:`UserController``UserService`
### 1.3 方法命名
- 使用小驼峰命名法(camelCase)
- 示例:`getUserById``createUser`
### 1.4 变量命名
- 使用小驼峰命名法(camelCase)
- 常量使用全大写+下划线(UPPER_SNAKE_CASE
- 示例:`userName``MAX_RETRY_COUNT`
## 2. 导入包规范
### 2.1 导入方式规范
**重要原则:必须使用常规的import方式导入类,严禁在代码中使用全路径限定名方式导入类。**
#### 2.1.1 正确的导入方式
```java
// ✅ 正确示例:使用import语句导入类
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.emotionmuseum.dto.request.CreateUserRequest;
import com.emotionmuseum.dto.response.UserResponse;
import com.emotionmuseum.service.UserService;
import com.emotionmuseum.common.result.Result;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@PostMapping
public Result<UserResponse> createUser(@RequestBody CreateUserRequest request) {
UserResponse response = userService.createUser(request);
return Result.success(response);
}
}
```
#### 2.1.2 错误的导入方式
```java
// ❌ 错误示例:在代码中使用全路径限定名
public class UserController {
@org.springframework.beans.factory.annotation.Autowired
private com.emotionmuseum.service.UserService userService;
@org.springframework.web.bind.annotation.PostMapping
public com.emotionmuseum.common.result.Result<com.emotionmuseum.dto.response.UserResponse>
createUser(@org.springframework.web.bind.annotation.RequestBody
com.emotionmuseum.dto.request.CreateUserRequest request) {
com.emotionmuseum.dto.response.UserResponse response = userService.createUser(request);
return com.emotionmuseum.common.result.Result.success(response);
}
}
```
### 2.2 导入顺序规范
```java
// 1. Java标准库导入
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
// 2. 第三方库导入(按字母顺序)
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
// 3. 项目内部导入(按包层次顺序)
import com.emotionmuseum.common.result.Result;
import com.emotionmuseum.dto.request.CreateUserRequest;
import com.emotionmuseum.dto.response.UserResponse;
import com.emotionmuseum.service.UserService;
```
### 2.3 导入优化规范
```java
// ✅ 推荐:使用通配符导入相关的类
import org.springframework.web.bind.annotation.*;
// ✅ 推荐:分别导入不同包的类
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
// ❌ 不推荐:过度使用通配符导入
import org.springframework.*;
// ❌ 不推荐:导入未使用的类
import java.util.ArrayList; // 如果代码中没有使用ArrayList
```
### 2.4 静态导入规范
```java
// ✅ 正确:静态导入常用的静态方法
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// ✅ 正确:静态导入项目中的工具类方法
import static com.emotionmuseum.common.util.DateUtil.formatDateTime;
import static com.emotionmuseum.common.util.StringUtil.isBlank;
// ❌ 错误:避免过度使用静态导入
import static java.lang.Math.*;
import static java.util.Collections.*;
```
### 2.5 导入冲突处理
```java
// 当出现类名冲突时,使用import别名或明确指定包名
import com.emotionmuseum.dto.response.Result;
import org.springframework.http.ResponseEntity;
public class UserController {
// 使用别名避免冲突
public com.emotionmuseum.dto.response.Result<UserResponse> createUser() {
// 业务逻辑
}
// 或者使用import别名(如果IDE支持)
// import com.emotionmuseum.dto.response.Result as EmotionResult;
}
```
## 3. 代码结构规范
### 3.1 类结构顺序
```java
public class ExampleClass {
// 1. 静态常量
public static final String CONSTANT = "value";
// 2. 实例变量
private String instanceVariable;
// 3. 构造方法
public ExampleClass() {}
// 4. 公共方法
public void publicMethod() {}
// 5. 私有方法
private void privateMethod() {}
}
```
### 3.2 方法结构
```java
public Result<UserResponse> getUserById(Long userId) {
// 1. 参数校验
if (userId == null) {
throw new IllegalArgumentException("用户ID不能为空");
}
// 2. 业务逻辑
User user = userService.getById(userId);
// 3. 数据转换
UserResponse response = UserConverter.toResponse(user);
// 4. 返回结果
return Result.success(response);
}
```
## 4. 注释规范
### 4.1 类注释
```java
/**
* 用户控制器
*
* @author 作者名
* @since 1.0.0
*/
@RestController
public class UserController {
}
```
### 4.2 方法注释
```java
/**
* 根据用户ID获取用户信息
*
* @param userId 用户ID
* @return 用户信息响应对象
* @throws UserNotFoundException 当用户不存在时抛出
*/
public Result<UserResponse> getUserById(Long userId) {
}
```
## 5. 异常处理规范
### 5.1 异常定义
```java
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(String message) {
super(message);
}
}
```
### 5.2 异常抛出
```java
if (user == null) {
throw new UserNotFoundException("用户不存在,ID: " + userId);
}
```
## 6. Log4j2日志规范
### 6.1 日志级别使用
```java
// FATAL: 致命错误,系统无法继续运行
logger.fatal("系统严重错误,无法继续运行", e);
// ERROR: 系统错误
logger.error("数据库连接失败", e);
// WARN: 警告信息
logger.warn("用户{}尝试访问未授权资源", userId);
// INFO: 重要业务信息
logger.info("用户{}登录成功", username);
// DEBUG: 调试信息
logger.debug("查询用户参数: {}", queryParams);
// TRACE: 详细调试信息
logger.trace("进入方法: {}", methodName);
```
### 6.2 日志记录规范
```java
// 使用占位符,避免字符串拼接
logger.info("用户{}在{}登录成功", username, LocalDateTime.now());
// 异常日志记录
try {
// 业务逻辑
} catch (Exception e) {
logger.error("处理用户请求失败,用户ID: {}, 错误信息: {}", userId, e.getMessage(), e);
throw new BusinessException("处理失败");
}
// 性能日志记录
long startTime = System.currentTimeMillis();
// 业务逻辑
long endTime = System.currentTimeMillis();
logger.info("查询用户信息耗时: {}ms", endTime - startTime);
```
### 6.3 MDC日志追踪
```java
// 设置MDC信息
MDC.put("userId", userId.toString());
MDC.put("requestId", UUID.randomUUID().toString());
try {
// 业务逻辑
} finally {
// 清理MDC
MDC.clear();
}
```
## 7. 数据库操作规范
### 7.1 MyBatis-Plus使用
```java
// 查询单个
User user = userMapper.selectById(userId);
// 条件查询
List<User> users = userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getStatus, UserStatus.ACTIVE)
.orderByDesc(User::getCreateTime)
);
```
## 8. 接口设计规范
### 8.1 Controller层规范
**重要原则:Controller层只负责接收请求、参数校验、调用Service层、返回结果,严禁在Controller层编写任何业务逻辑代码。**
```java
@RestController
@RequestMapping("/api/users")
@Validated
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public Result<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// Controller层只做:参数校验(通过@Valid)、调用Service、返回结果
UserResponse response = userService.createUser(request);
return Result.success(response);
}
@GetMapping("/{id}")
public Result<UserResponse> getUserById(@PathVariable Long id) {
// Controller层只做:调用Service、返回结果
UserResponse response = userService.getUserById(id);
return Result.success(response);
}
@PutMapping("/{id}")
public Result<UserResponse> updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
// Controller层只做:参数校验、调用Service、返回结果
UserResponse response = userService.updateUser(id, request);
return Result.success(response);
}
@DeleteMapping("/{id}")
public Result<Void> deleteUser(@PathVariable Long id) {
// Controller层只做:调用Service、返回结果
userService.deleteUser(id);
return Result.success(null);
}
}
```
### 8.2 Controller层禁止事项
```java
// ❌ 错误示例:在Controller层编写业务逻辑
@PostMapping("/users")
public Result<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// 错误:在Controller层进行业务判断
if (userMapper.selectByUsername(request.getUsername()) != null) {
throw new BusinessException("用户名已存在");
}
// 错误:在Controller层进行数据转换
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
// 错误:在Controller层直接操作数据库
userMapper.insert(user);
// 错误:在Controller层进行复杂的数据处理
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setUsername(user.getUsername());
return Result.success(response);
}
// ✅ 正确示例:Controller层只负责调用Service
@PostMapping("/users")
public Result<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
UserResponse response = userService.createUser(request);
return Result.success(response);
}
```
### 8.3 请求响应对象规范
```java
@Data
public class CreateUserRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
@Data
public class UserResponse {
private Long id;
private String username;
private LocalDateTime createTime;
}
```
### 8.4 Service层业务逻辑规范
**重要原则:所有业务逻辑必须在Service层完成,包括数据校验、业务规则判断、数据处理、事务管理等。**
```java
@Service
@Transactional
@Slf4j
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserResponse createUser(CreateUserRequest request) {
// 1. 业务参数校验
validateCreateUserRequest(request);
// 2. 业务规则判断
checkUserExists(request.getUsername());
// 3. 数据转换和业务处理
User user = convertToUser(request);
user.setPassword(passwordEncoder.encode(request.getPassword()));
// 4. 数据持久化
userMapper.insert(user);
// 5. 返回结果转换
return convertToResponse(user);
}
@Override
public UserResponse getUserById(Long id) {
// 1. 参数校验
if (id == null) {
throw new IllegalArgumentException("用户ID不能为空");
}
// 2. 业务逻辑:查询用户
User user = userMapper.selectById(id);
if (user == null) {
throw new UserNotFoundException("用户不存在,ID: " + id);
}
// 3. 返回结果转换
return convertToResponse(user);
}
@Override
public UserResponse updateUser(Long id, UpdateUserRequest request) {
// 1. 参数校验
validateUpdateUserRequest(id, request);
// 2. 业务逻辑:检查用户是否存在
User existingUser = userMapper.selectById(id);
if (existingUser == null) {
throw new UserNotFoundException("用户不存在,ID: " + id);
}
// 3. 业务规则判断
if (StringUtils.hasText(request.getUsername())) {
checkUserExists(request.getUsername(), id);
}
// 4. 数据更新
updateUserFields(existingUser, request);
userMapper.updateById(existingUser);
// 5. 返回结果转换
return convertToResponse(existingUser);
}
@Override
public void deleteUser(Long id) {
// 1. 参数校验
if (id == null) {
throw new IllegalArgumentException("用户ID不能为空");
}
// 2. 业务逻辑:检查用户是否存在
User user = userMapper.selectById(id);
if (user == null) {
throw new UserNotFoundException("用户不存在,ID: " + id);
}
// 3. 业务规则判断:检查是否可以删除
checkUserCanBeDeleted(user);
// 4. 执行删除
userMapper.deleteById(id);
log.info("用户删除成功,ID: {}", id);
}
// 私有方法:业务校验
private void validateCreateUserRequest(CreateUserRequest request) {
if (request == null) {
throw new IllegalArgumentException("请求参数不能为空");
}
// 其他业务校验逻辑
}
private void checkUserExists(String username) {
User existingUser = userMapper.selectByUsername(username);
if (existingUser != null) {
throw new BusinessException("用户名已存在: " + username);
}
}
private void checkUserExists(String username, Long excludeId) {
User existingUser = userMapper.selectByUsernameAndIdNot(username, excludeId);
if (existingUser != null) {
throw new BusinessException("用户名已存在: " + username);
}
}
private void checkUserCanBeDeleted(User user) {
// 检查用户是否可以删除的业务逻辑
if (user.getStatus() == UserStatus.ACTIVE) {
// 检查是否有未完成的订单等
if (hasUnfinishedOrders(user.getId())) {
throw new BusinessException("用户有未完成的订单,无法删除");
}
}
}
// 私有方法:数据转换
private User convertToUser(CreateUserRequest request) {
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setStatus(UserStatus.ACTIVE);
return user;
}
private UserResponse convertToResponse(User user) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setUsername(user.getUsername());
response.setEmail(user.getEmail());
response.setStatus(user.getStatus());
response.setCreateTime(user.getCreateTime());
return response;
}
}
```
## 9. 缓存使用规范
### 9.1 Redis缓存注解
```java
@Cacheable(value = "user", key = "#userId")
public UserResponse getUserById(Long userId) {
// 业务逻辑
}
@CacheEvict(value = "user", key = "#userId")
public void updateUser(Long userId, UpdateUserRequest request) {
// 更新逻辑
}
```
## 10. 异步处理规范
### 10.1 异步方法定义
```java
@Async("notificationExecutor")
public CompletableFuture<Void> sendNotificationAsync(Long userId, String message) {
try {
sendNotification(userId, message);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
logger.error("发送通知失败", e);
return CompletableFuture.failedFuture(e);
}
}
```
## 11. 单元测试规范
### 11.1 测试类结构
```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserMapper userMapper;
@InjectMocks
private UserServiceImpl userService;
@Test
@DisplayName("根据ID获取用户 - 成功")
void getUserById_Success() {
// Given
Long userId = 1L;
User user = new User();
user.setId(userId);
when(userMapper.selectById(userId)).thenReturn(user);
// When
UserResponse result = userService.getUserById(userId);
// Then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(userId);
}
}
```
## 12. Spring Security安全规范
### 12.1 安全注解使用
```java
@RestController
@RequestMapping("/api/users")
public class UserController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin")
public Result<List<UserResponse>> getAllUsers() {
// 只有ADMIN角色可以访问
}
@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
@GetMapping("/{userId}")
public Result<UserResponse> getUserById(@PathVariable Long userId) {
// 只有USER角色且只能访问自己的信息
}
@PreAuthorize("permitAll()")
@PostMapping("/register")
public Result<UserResponse> register(@Valid @RequestBody CreateUserRequest request) {
// 允许所有人访问
}
}
```
### 12.2 输入验证
```java
@PostMapping("/users")
public Result<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.createUser(request);
}
```
### 12.3 敏感信息处理
```java
// 日志中脱敏
logger.info("用户{}登录成功", maskUsername(username));
private String maskUsername(String username) {
if (username == null || username.length() <= 2) {
return username;
}
return username.substring(0, 1) + "***" + username.substring(username.length() - 1);
}
```
### 12.4 密码处理规范
```java
@Service
public class UserServiceImpl implements UserService {
@Autowired
private PasswordEncoder passwordEncoder;
public void createUser(CreateUserRequest request) {
// 密码加密存储
String encodedPassword = passwordEncoder.encode(request.getPassword());
user.setPassword(encodedPassword);
// 密码验证
if (passwordEncoder.matches(rawPassword, encodedPassword)) {
// 密码正确
}
}
}
```
### 12.5 JWT Token处理
```java
@Component
public class JwtTokenProvider {
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUsernameFromToken(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
```
## 13. 版本控制规范
### 13.1 Git提交规范
```bash
feat(user): 添加用户注册功能
fix(auth): 修复登录验证bug
docs(api): 更新API文档
style(code): 格式化代码
refactor(service): 重构用户服务
```
## 总结
本代码规范涵盖了Java开发中的主要方面,包括命名规范、代码结构、注释规范、异常处理、日志记录、数据库操作、接口设计、缓存使用、异步处理、单元测试、安全规范和版本控制等。遵循这些规范可以确保代码的质量、可维护性和可扩展性。
遵循这些规范可以确保代码的质量、可维护性和可扩展性,提高团队开发效率。
+397
View File
@@ -0,0 +1,397 @@
# SpringBoot单体后端服务技术方案
## 1. 技术选型
### 1.1 核心框架
- **Spring Boot**: 3.2.0 (最新稳定版本)
- **Java版本**: JDK 21 (LTS版本)
- **Spring AI**: 0.8.0 (最新稳定版本)
- **WebSocket**: Spring WebSocket 6.1.0
### 1.2 数据存储
- **数据库**: MySQL 8.0+
- **缓存**: Redis 7.0+
- **ORM框架**: MyBatis-Plus 3.5.4 (最新稳定版本)
### 1.3 其他组件
- **连接池**: HikariCP (Spring Boot默认)
- **JSON处理**: Jackson
- **API文档**: SpringDoc OpenAPI 3
- **安全框架**: Spring Security 6.1.0
- **日志**: Log4j2 + SLF4J
- **测试**: JUnit 5 + Mockito
## 2. 项目架构设计
### 2.1 分层架构
```
src/main/java/com/emotionmuseum/
├── config/ # 配置类
├── controller/ # 控制器层(只负责接收请求、参数校验、调用Service、返回结果)
├── service/ # 服务层(所有业务逻辑都在这里实现)
│ ├── impl/ # 服务实现
├── mapper/ # 数据访问层
├── entity/ # 实体类
├── dto/ # 数据传输对象
│ ├── request/ # 请求对象
│ ├── response/ # 响应对象
├── common/ # 公共组件
│ ├── base/ # 基础类
│ ├── exception/ # 异常处理
│ ├── result/ # 统一返回结果
│ ├── util/ # 工具类
├── websocket/ # WebSocket相关
└── EmotionMuseumApplication.java
```
### 2.2 分层职责规范
- **Controller层**: 只负责接收HTTP请求、参数校验、调用Service层方法、返回HTTP响应
- **Service层**: 负责所有业务逻辑,包括数据校验、业务规则判断、数据处理、事务管理
- **Mapper层**: 只负责数据库操作,不包含业务逻辑
- **Entity层**: 数据库实体类,对应数据库表结构
- **DTO层**: 数据传输对象,用于前后端数据交互
### 2.3 包结构规范
- 按功能模块分包
- 每个模块包含完整的MVC层次
- 公共组件独立包管理
## 3. 核心配置设计
### 3.1 多环境配置
- `application.yml` - 主配置文件
- `application-dev.yml` - 开发环境
- `application-test.yml` - 测试环境
- `application-prod.yml` - 生产环境
### 3.2 异步配置
- 使用`@EnableAsync`启用异步
- 配置自定义线程池
- 支持异步方法调用
### 3.3 数据库配置
- 主从分离支持
- 连接池优化配置
- 事务管理配置
## 4. 代码规范
### 4.1 命名规范
- **类名**: 大驼峰命名法 (PascalCase)
- **方法名**: 小驼峰命名法 (camelCase)
- **常量**: 全大写+下划线 (UPPER_SNAKE_CASE)
- **包名**: 全小写+点分隔 (com.emotionmuseum)
### 4.2 注解规范
- 控制器类使用`@RestController`
- 服务类使用`@Service`
- 数据访问类使用`@Mapper`
- 异步方法使用`@Async`
### 4.3 异常处理规范
- 统一使用全局异常处理器
- 自定义业务异常类
- 异常信息国际化支持
## 5. 核心组件设计
### 5.1 BaseEntity设计
```java
@MappedSuperclass
@Data
public abstract class BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableLogic
private Integer deleted;
}
```
### 5.2 统一返回结果设计
```java
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> Result<T> success(T data) {
// 成功返回
}
public static <T> Result<T> error(String message) {
// 错误返回
}
}
```
### 5.3 请求响应封装
- 所有Controller入参使用Request对象封装
- 所有Controller出参使用Response对象封装
- 统一使用Result包装返回结果
### 5.4 分层职责严格规范
**重要原则:严格遵循分层架构,各层职责明确,禁止跨层调用。**
- **Controller层职责**
- 接收HTTP请求
- 参数校验(使用@Valid注解
- 调用Service层方法
- 返回HTTP响应
- **严禁在Controller层编写任何业务逻辑代码**
- **Service层职责**
- 所有业务逻辑实现
- 数据校验和业务规则判断
- 数据处理和转换
- 事务管理
- 调用Mapper层进行数据操作
- 异常处理和日志记录
- **Mapper层职责**
- 数据库CRUD操作
- SQL语句编写
- 数据查询和映射
- **严禁在Mapper层编写业务逻辑**
## 6. Spring AI + Coze集成方案
### 6.1 Coze平台集成
- 使用Spring AI的ChatClient接口
- 配置Coze API密钥和端点
- 实现自定义ChatClient适配器
### 6.2 WebSocket实时通信
- 使用STOMP协议
- 支持一对一和广播消息
- 实现消息持久化
### 6.3 对话管理
- 对话历史记录存储
- 上下文管理
- 用户会话隔离
## 7. 缓存策略
### 7.1 Redis缓存设计
- 用户会话缓存
- 对话历史缓存
- 热点数据缓存
- 分布式锁实现
### 7.2 缓存更新策略
- 写入时更新
- 定时刷新
- 失效策略
## 8. 安全设计
### 8.1 Spring Security认证授权
- **JWT Token认证**: 基于JWT的无状态认证机制
- **角色权限控制**: 基于RBAC的权限模型
- **API访问控制**: 细粒度的接口权限控制
- **密码加密**: 使用BCrypt加密算法
- **会话管理**: 支持无状态和有状态会话
- **CSRF防护**: 跨站请求伪造防护
- **CORS配置**: 跨域资源共享配置
### 8.2 安全组件设计
- **SecurityConfig**: Spring Security主配置类
- **JwtAuthenticationFilter**: JWT认证过滤器
- **JwtTokenProvider**: JWT令牌提供者
- **UserDetailsService**: 用户详情服务
- **AuthenticationEntryPoint**: 认证失败处理
- **AccessDeniedHandler**: 访问拒绝处理
### 8.3 数据安全
- 敏感数据加密
- SQL注入防护
- XSS防护
- 输入验证和过滤
## 9. 性能优化
### 9.1 数据库优化
- 索引优化
- 查询优化
- 分页查询
### 9.2 缓存优化
- 多级缓存
- 缓存预热
- 缓存穿透防护
### 9.3 异步处理
- 消息队列
- 异步任务
- 批量处理
## 10. 监控和日志
### 10.1 应用监控
- 健康检查
- 性能监控
- 业务监控
### 10.2 Log4j2日志管理
- **日志框架**: Log4j2 + SLF4J
- **日志级别**: TRACE, DEBUG, INFO, WARN, ERROR, FATAL
- **日志输出**: 控制台、文件、数据库、远程服务器
- **日志格式**: JSON格式结构化日志
- **日志轮转**: 按大小和时间自动轮转
- **日志过滤**: 基于MDC的日志过滤
- **性能优化**: 异步日志记录
- **日志聚合**: ELK Stack集成支持
### 10.3 日志配置策略
- **开发环境**: 控制台输出,DEBUG级别
- **测试环境**: 文件输出,INFO级别
- **生产环境**: 文件+远程输出,WARN级别
- **安全日志**: 独立的审计日志文件
- **业务日志**: 按模块分离的日志文件
## 11. 部署方案
### 11.1 容器化部署
- Docker镜像构建
- Docker Compose编排
- 环境变量配置
### 11.2 CI/CD流程
- 自动化构建
- 自动化测试
- 自动化部署
## 12. 开发规范
### 12.1 代码提交规范
- 使用Conventional Commits
- 分支管理策略
- 代码审查流程
### 12.2 测试规范
- 单元测试覆盖率 > 80%
- 集成测试
- 端到端测试
### 12.3 文档规范
- API文档自动生成
- 代码注释规范
- 技术文档维护
## 13. 项目依赖管理
### 13.1 Maven依赖
```xml
<properties>
<java.version>21</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<spring-ai.version>0.8.0</spring-ai.version>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<spring-security.version>6.1.0</spring-security.version>
<log4j2.version>2.20.0</log4j2.version>
<jwt.version>0.11.5</jwt.version>
</properties>
```
### 13.2 依赖版本管理
- 统一版本管理
- 依赖冲突解决
- 安全漏洞检查
### 13.3 核心依赖配置
```xml
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
```
## 14. 开发工具配置
### 14.1 IDE配置
- IntelliJ IDEA推荐配置
- 代码格式化规则
- 代码检查规则
### 14.2 构建工具配置
- Maven配置优化
- 插件配置
- 构建脚本
## 15. 质量保证
### 15.1 代码质量
- SonarQube代码检查
- 代码规范检查
- 性能检查
### 15.2 测试质量
- 测试用例设计
- 测试数据管理
- 测试环境管理
---
## 总结
本技术方案提供了一个完整的SpringBoot单体后端服务架构,涵盖了从技术选型到部署运维的各个方面。方案注重:
1. **技术先进性**: 使用最新稳定版本的技术栈
2. **架构合理性**: 分层清晰,职责明确
3. **可扩展性**: 支持水平扩展和功能扩展
4. **可维护性**: 统一的代码规范和开发流程
5. **高性能**: 多级缓存和异步处理
6. **高可用**: 完善的监控和异常处理机制
该方案可以作为项目开发的技术指导文档,确保项目的技术实现符合最佳实践。
+764
View File
@@ -0,0 +1,764 @@
# 项目结构示例
## 1. 目录结构
```
src/main/java/com/emotionmuseum/
├── EmotionMuseumApplication.java # 启动类
├── config/ # 配置类
│ ├── AsyncConfig.java # 异步配置
│ ├── MybatisPlusConfig.java # MyBatis-Plus配置
│ ├── RedisConfig.java # Redis配置
│ ├── WebSocketConfig.java # WebSocket配置
│ ├── SecurityConfig.java # Spring Security配置
│ ├── JwtConfig.java # JWT配置
│ └── SwaggerConfig.java # API文档配置
├── controller/ # 控制器层
│ ├── UserController.java # 用户控制器
│ ├── ChatController.java # 聊天控制器
│ └── AuthController.java # 认证控制器
├── service/ # 服务层
│ ├── UserService.java # 用户服务接口
│ ├── ChatService.java # 聊天服务接口
│ ├── AuthService.java # 认证服务接口
│ └── impl/ # 服务实现
│ ├── UserServiceImpl.java
│ ├── ChatServiceImpl.java
│ └── AuthServiceImpl.java
├── mapper/ # 数据访问层
│ ├── UserMapper.java
│ ├── ChatMapper.java
│ └── MessageMapper.java
├── entity/ # 实体类
│ ├── User.java
│ ├── Chat.java
│ ├── Message.java
│ └── BaseEntity.java
├── dto/ # 数据传输对象
│ ├── request/ # 请求对象
│ │ ├── CreateUserRequest.java
│ │ ├── LoginRequest.java
│ │ ├── ChatRequest.java
│ │ └── PageRequest.java
│ └── response/ # 响应对象
│ ├── UserResponse.java
│ ├── LoginResponse.java
│ ├── ChatResponse.java
│ └── PageResult.java
├── common/ # 公共组件
│ ├── base/ # 基础类
│ │ ├── BaseEntity.java
│ │ └── BaseService.java
│ ├── exception/ # 异常处理
│ │ ├── GlobalExceptionHandler.java
│ │ ├── BusinessException.java
│ │ └── UserNotFoundException.java
│ ├── result/ # 统一返回结果
│ │ ├── Result.java
│ │ └── ResultCode.java
│ ├── security/ # 安全相关
│ │ ├── JwtAuthenticationFilter.java # JWT认证过滤器
│ │ ├── JwtTokenProvider.java # JWT令牌提供者
│ │ ├── UserDetailsServiceImpl.java # 用户详情服务实现
│ │ ├── AuthenticationEntryPointImpl.java # 认证失败处理
│ │ └── AccessDeniedHandlerImpl.java # 访问拒绝处理
│ └── util/ # 工具类
│ ├── JwtUtil.java
│ ├── RedisUtil.java
│ └── DateUtil.java
├── websocket/ # WebSocket相关
│ ├── WebSocketHandler.java
│ ├── ChatWebSocketHandler.java
│ └── dto/
│ ├── ChatMessage.java
│ └── WebSocketMessage.java
└── ai/ # AI相关
├── CozeClient.java # Coze客户端
├── ChatClient.java # 聊天客户端
└── config/
└── CozeConfig.java # Coze配置
```
## 3. 核心代码示例
### 3.1 启动类
```java
@SpringBootApplication
@EnableAsync
@EnableCaching
@EnableGlobalMethodSecurity(prePostEnabled = true)
@MapperScan("com.emotionmuseum.mapper")
public class EmotionMuseumApplication {
public static void main(String[] args) {
SpringApplication.run(EmotionMuseumApplication.class, args);
}
}
```
```
## 2. 核心代码示例
### 2.1 启动类
```java
@SpringBootApplication
@EnableAsync
@EnableCaching
@EnableGlobalMethodSecurity(prePostEnabled = true)
@MapperScan("com.emotionmuseum.mapper")
public class EmotionMuseumApplication {
public static void main(String[] args) {
SpringApplication.run(EmotionMuseumApplication.class, args);
}
}
```
### 2.2 BaseEntity
```java
@Data
@MappedSuperclass
public abstract class BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableLogic
private Integer deleted;
}
```
### 2.3 统一返回结果
```java
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
result.setData(data);
result.setTimestamp(System.currentTimeMillis());
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(ResultCode.ERROR.getCode());
result.setMessage(message);
result.setTimestamp(System.currentTimeMillis());
return result;
}
}
```
### 2.4 全局异常处理
```java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.warn("参数验证失败: {}", message);
return Result.error(message);
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统异常,请稍后重试");
}
}
```
### 2.5 异步配置
```java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}
```
### 2.6 Coze客户端
```java
@Component
@Slf4j
public class CozeClient {
@Autowired
private RestTemplate restTemplate;
@Value("${coze.api.url}")
private String cozeApiUrl;
@Value("${coze.api.key}")
private String cozeApiKey;
public String chat(String message, String sessionId) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + cozeApiKey);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("message", message);
requestBody.put("session_id", sessionId);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
cozeApiUrl + "/chat", request, Map.class);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
return (String) response.getBody().get("response");
}
throw new BusinessException("调用Coze API失败");
} catch (Exception e) {
log.error("调用Coze API异常", e);
throw new BusinessException("AI服务暂时不可用");
}
}
}
```
### 2.7 WebSocket配置
```java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new ChatWebSocketHandler(), "/ws/chat")
.setAllowedOrigins("*");
}
}
```
### 2.8 Spring Security配置
```java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/ws/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
```
### 2.9 JWT认证过滤器
```java
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
String username = jwtTokenProvider.getUsernameFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("无法设置用户认证: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
```
### 2.10 聊天控制器
```java
@RestController
@RequestMapping("/api/chat")
@Validated
@Slf4j
public class ChatController {
@Autowired
private ChatService chatService;
@PreAuthorize("hasRole('USER')")
@PostMapping("/send")
public Result<ChatResponse> sendMessage(@Valid @RequestBody ChatRequest request) {
// Controller层只负责:参数校验、调用Service、返回结果
ChatResponse response = chatService.sendMessage(request);
return Result.success(response);
}
@PreAuthorize("hasRole('USER')")
@GetMapping("/history")
public Result<PageResult<ChatResponse>> getChatHistory(
@Valid PageRequest pageRequest,
@RequestParam(required = false) String sessionId) {
// Controller层只负责:参数校验、调用Service、返回结果
PageResult<ChatResponse> result = chatService.getChatHistory(pageRequest, sessionId);
return Result.success(result);
}
}
```
### 2.11 聊天服务实现
```java
@Service
@Transactional
@Slf4j
public class ChatServiceImpl implements ChatService {
@Autowired
private ChatMapper chatMapper;
@Autowired
private CozeClient cozeClient;
@Override
public ChatResponse sendMessage(ChatRequest request) {
// 1. 业务参数校验
validateChatRequest(request);
// 2. 业务逻辑:调用AI服务
String aiResponse = cozeClient.chat(request.getMessage(), request.getSessionId());
// 3. 业务逻辑:保存聊天记录
Chat chat = new Chat();
chat.setUserId(getCurrentUserId());
chat.setMessage(request.getMessage());
chat.setResponse(aiResponse);
chat.setSessionId(request.getSessionId());
chat.setCreateTime(LocalDateTime.now());
chatMapper.insert(chat);
// 4. 返回结果转换
return convertToChatResponse(chat);
}
@Override
public PageResult<ChatResponse> getChatHistory(PageRequest pageRequest, String sessionId) {
// 1. 业务逻辑:查询聊天历史
Page<Chat> page = new Page<>(pageRequest.getPageNum(), pageRequest.getPageSize());
LambdaQueryWrapper<Chat> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Chat::getUserId, getCurrentUserId());
if (StringUtils.hasText(sessionId)) {
wrapper.eq(Chat::getSessionId, sessionId);
}
wrapper.orderByDesc(Chat::getCreateTime);
Page<Chat> chatPage = chatMapper.selectPage(page, wrapper);
// 2. 数据转换
List<ChatResponse> responses = chatPage.getRecords().stream()
.map(this::convertToChatResponse)
.collect(Collectors.toList());
// 3. 返回分页结果
return new PageResult<>(responses, chatPage.getTotal(), pageRequest.getPageNum(), pageRequest.getPageSize());
}
// 私有方法:业务校验
private void validateChatRequest(ChatRequest request) {
if (request == null) {
throw new IllegalArgumentException("请求参数不能为空");
}
if (!StringUtils.hasText(request.getMessage())) {
throw new IllegalArgumentException("消息内容不能为空");
}
if (request.getMessage().length() > 1000) {
throw new IllegalArgumentException("消息内容长度不能超过1000字符");
}
}
// 私有方法:获取当前用户ID
private Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 根据用户名获取用户ID的逻辑
return getUserService().getUserIdByUsername(userDetails.getUsername());
}
throw new UnauthorizedException("用户未登录");
}
// 私有方法:数据转换
private ChatResponse convertToChatResponse(Chat chat) {
ChatResponse response = new ChatResponse();
response.setId(chat.getId());
response.setMessage(chat.getMessage());
response.setResponse(chat.getResponse());
response.setSessionId(chat.getSessionId());
response.setCreateTime(chat.getCreateTime());
return response;
}
}
```
## 3. 配置文件示例
### 3.1 application.yml
```yaml
spring:
profiles:
active: dev
application:
name: emotion-museum
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: password
hikari:
maximum-pool-size: 20
minimum-idle: 5
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# JWT配置
jwt:
secret: your-secret-key-here-must-be-very-long-and-secure
expiration: 86400000 # 24小时
coze:
api:
url: https://api.coze.com
key: your-coze-api-key
# Log4j2配置
logging:
config: classpath:log4j2-spring.xml
```
### 3.2 application-dev.yml
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/emotion_museum_dev?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
redis:
host: localhost
port: 6379
# 开发环境JWT配置
jwt:
secret: dev-secret-key-not-for-production
expiration: 86400000
```
### 3.3 log4j2-spring.xml
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" monitorInterval="30">
<Properties>
<Property name="LOG_PATTERN">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
<Property name="LOG_FILE_PATH">logs</Property>
</Properties>
<Appenders>
<!-- 控制台输出 -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${LOG_PATTERN}"/>
</Console>
<!-- 文件输出 -->
<RollingFile name="FileAppender" fileName="${LOG_FILE_PATH}/app.log"
filePattern="${LOG_FILE_PATH}/app-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="${LOG_PATTERN}"/>
<Policies>
<TimeBasedTriggeringPolicy />
<SizeBasedTriggeringPolicy size="100MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
<!-- 错误日志文件 -->
<RollingFile name="ErrorFileAppender" fileName="${LOG_FILE_PATH}/error.log"
filePattern="${LOG_FILE_PATH}/error-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="${LOG_PATTERN}"/>
<Policies>
<TimeBasedTriggeringPolicy />
<SizeBasedTriggeringPolicy size="100MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
<!-- 安全日志文件 -->
<RollingFile name="SecurityFileAppender" fileName="${LOG_FILE_PATH}/security.log"
filePattern="${LOG_FILE_PATH}/security-%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="${LOG_PATTERN}"/>
<Policies>
<TimeBasedTriggeringPolicy />
<SizeBasedTriggeringPolicy size="100MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
</Appenders>
<Loggers>
<!-- 应用日志 -->
<Logger name="com.emotionmuseum" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="FileAppender"/>
</Logger>
<!-- 安全日志 -->
<Logger name="com.emotionmuseum.common.security" level="info" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="SecurityFileAppender"/>
</Logger>
<!-- 错误日志 -->
<Logger name="com.emotionmuseum" level="error" additivity="false">
<AppenderRef ref="ErrorFileAppender"/>
</Logger>
<!-- Spring Security日志 -->
<Logger name="org.springframework.security" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="SecurityFileAppender"/>
</Logger>
<!-- 根日志器 -->
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="FileAppender"/>
</Root>
</Loggers>
</Configuration>
```
## 4. Maven依赖
### 4.1 pom.xml核心依赖
```xml
<properties>
<java.version>21</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<spring-ai.version>0.8.0</spring-ai.version>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<mysql.version>8.0.33</mysql.version>
<redis.version>3.2.0</redis.version>
<spring-security.version>6.1.0</spring-security.version>
<log4j2.version>2.20.0</log4j2.version>
<jwt.version>0.11.5</jwt.version>
</properties>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Test -->
<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>
```
这个项目结构示例提供了一个完整的SpringBoot单体后端服务的基础框架,包含了您要求的所有技术组件:Spring AI、WebSocket、Redis、MySQL、MyBatis-Plus、Spring Security、JWT认证、Log4j2日志、异步处理、全局异常处理、统一返回结果等。该框架具有以下特点:
1. **安全性**: 集成Spring Security和JWT,提供完整的认证授权机制
2. **可观测性**: 使用Log4j2提供结构化日志记录和日志分级管理
3. **高性能**: 支持异步处理、缓存和数据库优化
4. **可扩展性**: 模块化设计,易于扩展和维护
5. **标准化**: 统一的代码规范和异常处理机制
6. **分层清晰**: 严格遵循分层架构,Controller层只负责请求处理,所有业务逻辑都在Service层实现
-73
View File
@@ -1,73 +0,0 @@
-- 验证重复消息修复效果的SQL脚本
-- 1. 查看最新的消息记录(修复后应该只有一条用户消息)
SELECT
id,
conversation_id,
content,
sender,
user_id,
user_type,
coze_role,
create_by,
create_time,
update_time
FROM message
WHERE create_time > '2025-07-25 16:15:00' -- 修复后的时间
ORDER BY create_time DESC
LIMIT 20;
-- 2. 检查是否还有重复的用户消息(修复后应该返回0条记录)
SELECT
content,
conversation_id,
sender,
COUNT(*) as duplicate_count,
GROUP_CONCAT(id) as message_ids,
MIN(create_time) as first_time,
MAX(create_time) as last_time
FROM message
WHERE sender = 'user'
AND create_time > '2025-07-25 16:15:00' -- 修复后的时间
GROUP BY content, conversation_id, sender
HAVING COUNT(*) > 1;
-- 3. 查看特定会话的消息流(验证消息顺序正常)
-- 请将 'YOUR_CONVERSATION_ID' 替换为实际的会话ID
SELECT
id,
content,
sender,
user_id,
user_type,
create_time,
CASE
WHEN user_id IS NOT NULL AND user_type IS NOT NULL THEN 'WebSocket保存'
WHEN user_id IS NULL AND create_by != 'system' THEN 'REST API保存'
ELSE '其他方式保存'
END as save_method
FROM message
WHERE conversation_id = 'YOUR_CONVERSATION_ID'
ORDER BY create_time ASC;
-- 4. 统计修复前后的消息数量对比
SELECT
DATE(create_time) as date,
sender,
COUNT(*) as message_count
FROM message
WHERE create_time >= '2025-07-25 00:00:00'
GROUP BY DATE(create_time), sender
ORDER BY date DESC, sender;
-- 5. 查找可能的重复消息模式
SELECT
content,
sender,
COUNT(*) as count,
GROUP_CONCAT(DISTINCT user_id) as user_ids,
GROUP_CONCAT(DISTINCT create_by) as create_bys
FROM message
WHERE create_time > '2025-07-25 16:00:00'
GROUP BY content, sender
HAVING COUNT(*) > 1;
-70
View File
@@ -1,70 +0,0 @@
# 依赖目录
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 构建输出
dist
build
# 环境文件
.env.local
.env.development.local
.env.test.local
.env.production.local
# 日志文件
logs
*.log
# 运行时数据
pids
*.pid
*.seed
*.pid.lock
# 覆盖率目录
coverage
.nyc_output
# IDE文件
.vscode
.idea
*.swp
*.swo
*~
# OS生成的文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# 文档
README.md
*.md
# 测试文件
tests
test
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# 临时文件
tmp
temp
+9 -15
View File
@@ -1,22 +1,16 @@
# 开发环境配置
VITE_APP_ENV=dev
VITE_APP_TITLE=情绪博物馆 - 开发
VITE_APP_VERSION=1.0.0
# 应用配置
VITE_APP_TITLE=开心APP - 开发环境
VITE_APP_DESCRIPTION=你的情绪陪伴使者
# API配置 - 直接访问backend-single
# API配置
VITE_API_BASE_URL=http://localhost:19089/api
VITE_WS_BASE_URL=ws://localhost:19089/api
VITE_UPLOAD_URL=http://localhost:19089/api/upload
VITE_WS_URL=http://localhost:19089/api/ws/chat
# WebSocket配置
VITE_WS_RECONNECT_ATTEMPTS=5
VITE_WS_RECONNECT_INTERVAL=3000
VITE_WS_HEARTBEAT_INTERVAL=30000
# 环境标识
VITE_NODE_ENV=development
# 调试配置
VITE_DEBUG=true
VITE_LOG_LEVEL=debug
VITE_MOCK=false
# 其他配置
VITE_APP_DESCRIPTION=情绪博物馆Web系统 - 开发环境
+10 -14
View File
@@ -1,20 +1,16 @@
# 应用配置
VITE_APP_TITLE=开心APP
VITE_APP_DESCRIPTION=你的情绪陪伴使者
# 生产环境配置
VITE_APP_ENV=prod
VITE_APP_TITLE=情绪博物馆
VITE_APP_VERSION=1.0.0
# API配置 - 生产环境直接访问backend-single
# API配置
VITE_API_BASE_URL=http://47.111.10.27:19089/api
VITE_WS_BASE_URL=ws://47.111.10.27:19089/api
VITE_UPLOAD_URL=http://47.111.10.27:19089/api/upload
VITE_WS_URL=http://47.111.10.27:19089/api/ws/chat
# WebSocket配置
VITE_WS_RECONNECT_ATTEMPTS=10
VITE_WS_RECONNECT_INTERVAL=5000
VITE_WS_HEARTBEAT_INTERVAL=30000
# 环境标识
VITE_NODE_ENV=production
# 调试配置
VITE_DEBUG=false
VITE_LOG_LEVEL=error
VITE_MOCK=false
# 其他配置
VITE_APP_DESCRIPTION=情绪博物馆Web系统
+16
View File
@@ -0,0 +1,16 @@
# 测试环境配置
VITE_APP_ENV=test
VITE_APP_TITLE=情绪博物馆 - 测试
VITE_APP_VERSION=1.0.0
# API配置
VITE_API_BASE_URL=http://test.emotion-museum.com/api
VITE_WS_BASE_URL=ws://test.emotion-museum.com
VITE_UPLOAD_URL=http://test.emotion-museum.com/api/upload
# 调试配置
VITE_DEBUG=false
VITE_MOCK=false
# 其他配置
VITE_APP_DESCRIPTION=情绪博物馆Web系统 - 测试环境
+2 -4
View File
@@ -1,9 +1,7 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
@@ -14,7 +12,7 @@ module.exports = {
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
-115
View File
@@ -1,115 +0,0 @@
# 依赖
node_modules/
.pnp
.pnp.js
# 生产构建
/dist
/build
# 本地环境变量文件
.env.local
.env.development.local
.env.test.local
.env.production.local
# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# 运行时数据
pids
*.pid
*.seed
*.pid.lock
# 覆盖率报告
coverage
*.lcov
.nyc_output
# ESLint缓存
.eslintcache
# 可选的npm缓存目录
.npm
# 可选的eslint缓存
.eslintcache
# 微束缓存
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# 可选的REPL历史
.node_repl_history
# 输出的npm包
*.tgz
# Yarn完整性文件
.yarn-integrity
# dotenv环境变量文件
.env
.env.test
# parcel-bundler缓存
.cache
.parcel-cache
# Next.js构建输出
.next
# Nuxt.js构建/生成输出
.nuxt
dist
# Gatsby文件
.cache/
public
# Vuepress构建输出
.vuepress/dist
# Serverless目录
.serverless/
# FuseBox缓存
.fusebox/
# DynamoDB本地文件
.dynamodb/
# TernJS端口文件
.tern-port
# IDE和编辑器
.vscode/
.idea/
*.swp
*.swo
*~
# OS生成的文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 临时文件
tmp/
temp/
# 测试输出
test-results/
playwright-report/
playwright/.cache/
+2 -2
View File
@@ -4,6 +4,6 @@
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"endOfLine": "lf",
"vueIndentScriptAndStyle": true
"bracketSpacing": true,
"arrowParens": "avoid"
}
-54
View File
@@ -1,54 +0,0 @@
# 多阶段构建 Dockerfile for 开心APP前端
# 第一阶段:构建阶段
FROM node:18-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 第二阶段:生产阶段
FROM nginx:alpine AS production
# 安装必要的工具
RUN apk add --no-cache curl
# 复制自定义nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 从构建阶段复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 创建非root用户
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# 设置正确的权限
RUN chown -R nextjs:nodejs /usr/share/nginx/html && \
chown -R nextjs:nodejs /var/cache/nginx && \
chown -R nextjs:nodejs /var/log/nginx && \
chown -R nextjs:nodejs /etc/nginx/conf.d
# 切换到非root用户
USER nextjs
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]
-252
View File
@@ -1,252 +0,0 @@
# 开心APP - 前端应用
基于Vue 3 + Ant Design Vue的现代化情绪陪伴应用前端。
## 技术栈
- **Vue 3** - 渐进式JavaScript框架
- **TypeScript** - 类型安全的JavaScript超集
- **Ant Design Vue** - 企业级UI组件库
- **Vite** - 下一代前端构建工具
- **Vue Router** - 官方路由管理器
- **Pinia** - 状态管理库
- **Sass** - CSS预处理器
## 功能特性
- 🤖 **智能对话** - 与AI助手"开开"实时聊天
- 📝 **情绪日记** - 记录和分享日常心情
- 👤 **个人展板** - 自定义个人信息展示
- 📊 **话题追踪** - 关注和管理感兴趣的话题
- 📈 **数据可视化** - 心情统计图表
- ⚙️ **用户管理** - 登录、注册、设置
## 项目结构
```
src/
├── assets/ # 静态资源
│ ├── images/ # 图片资源
│ └── styles/ # 样式文件
├── components/ # 公共组件
│ ├── common/ # 通用组件
│ ├── layout/ # 布局组件
│ └── ui/ # UI组件
├── views/ # 页面组件
│ ├── Home/ # 首页
│ ├── Chat/ # 聊天页面
│ ├── Diary/ # 日记页面
│ ├── Dashboard/ # 个人展板
│ └── ...
├── router/ # 路由配置
├── stores/ # Pinia状态管理
├── services/ # API服务
├── utils/ # 工具函数
├── types/ # TypeScript类型定义
├── App.vue # 根组件
└── main.ts # 应用入口
```
## 快速开始
### 环境要求
- Node.js >= 16.0.0
- npm >= 8.0.0
### 安装依赖
```bash
npm install
```
### 开发模式
```bash
npm run dev
```
应用将在 http://localhost:3000 启动
### 构建生产版本
```bash
npm run build
```
### 预览生产版本
```bash
npm run preview
```
### 代码检查
```bash
npm run lint
```
### 代码格式化
```bash
npm run format
```
### 类型检查
```bash
npm run type-check
```
## 开发指南
### 项目启动
1. 克隆项目到本地
2. 安装依赖:`npm install`
3. 启动开发服务器:`npm run dev`
4. 在浏览器中打开 http://localhost:3000
### 开发流程
1. 创建新分支进行开发
2. 编写代码并确保通过所有检查
3. 提交代码并创建Pull Request
4. 代码审查通过后合并到主分支
### 代码规范
- 使用TypeScript进行类型安全开发
- 遵循ESLint和Prettier配置的代码规范
- 组件命名使用PascalCase
- 文件命名使用kebab-case
- 变量和函数使用camelCase
## 环境配置
### 开发环境
复制 `.env` 文件并根据需要修改配置:
```bash
cp .env.example .env
```
### 生产环境
配置 `.env.production` 文件中的生产环境变量。
## 部署
### 传统部署
#### 构建
```bash
npm run build
```
构建产物将生成在 `dist` 目录中。
#### 部署到服务器
`dist` 目录中的文件部署到Web服务器即可。
#### 使用部署脚本
```bash
# 开发环境
./deploy.sh dev
# 测试环境
./deploy.sh test
# 生产环境
./deploy.sh prod
```
### Docker部署
#### 构建Docker镜像
```bash
docker build -t emotion-museum-web .
```
#### 运行容器
```bash
docker run -d -p 3000:80 --name emotion-museum-web emotion-museum-web
```
#### 使用Docker Compose
```bash
# 生产模式
docker-compose up -d
# 开发模式
docker-compose --profile dev up -d
```
### Nginx配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend-server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## 开发规范
### 组件命名
- 组件名使用PascalCase
- 文件名使用kebab-case
- 变量和函数使用camelCase
### 代码风格
项目使用ESLint和Prettier进行代码规范检查和格式化。
### Git提交规范
使用约定式提交格式:
```
feat: 新功能
fix: 修复bug
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建过程或辅助工具的变动
```
## 浏览器支持
- Chrome >= 87
- Firefox >= 78
- Safari >= 14
- Edge >= 88
## 许可证
MIT License
## 联系方式
如有问题或建议,请联系开发团队。
+52
View File
@@ -0,0 +1,52 @@
# 部署脚本 - 将构建好的文件上传到服务器
# 使用方法: .\deploy.ps1
param(
[string]$ServerIP = "47.111.10.27",
[string]$Username = "root",
[string]$RemotePath = "/data/www/emotion-museum"
)
Write-Host "开始部署前端应用到服务器..." -ForegroundColor Green
# 检查dist目录是否存在
if (-not (Test-Path "dist")) {
Write-Host "错误: dist目录不存在,请先运行 npm run build" -ForegroundColor Red
exit 1
}
# 检查是否安装了scp命令(需要安装OpenSSH客户端)
try {
scp 2>&1 | Out-Null
} catch {
Write-Host "错误: 未找到scp命令,请安装OpenSSH客户端" -ForegroundColor Red
Write-Host "可以通过以下方式安装:" -ForegroundColor Yellow
Write-Host "1. Windows 10/11: 设置 -> 应用 -> 可选功能 -> 添加功能 -> OpenSSH客户端" -ForegroundColor Yellow
Write-Host "2. 或者使用 WinSCP 等工具手动上传" -ForegroundColor Yellow
exit 1
}
Write-Host "正在上传文件到服务器 $ServerIP..." -ForegroundColor Yellow
# 上传所有文件到服务器
try {
# 上传index.html
scp "dist/index.html" "${Username}@${ServerIP}:${RemotePath}/"
# 上传assets目录
scp -r "dist/assets" "${Username}@${ServerIP}:${RemotePath}/"
# 上传测试文件
scp "dist/test-*.html" "${Username}@${ServerIP}:${RemotePath}/"
Write-Host "部署完成!" -ForegroundColor Green
Write-Host "访问地址: http://$ServerIP/emotion-museum/" -ForegroundColor Cyan
} catch {
Write-Host "部署失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "请检查:" -ForegroundColor Yellow
Write-Host "1. 服务器IP地址是否正确" -ForegroundColor Yellow
Write-Host "2. SSH密钥是否配置正确" -ForegroundColor Yellow
Write-Host "3. 服务器目录权限是否正确" -ForegroundColor Yellow
exit 1
}
+38 -4
View File
@@ -1,5 +1,39 @@
#!/bin/bash
set -e
npm install
npm run build
echo "前端已打包,dist 目录可部署到 nginx/html 目录"
# 部署脚本 - 将构建好的文件上传到服务器
# 使用方法: ./deploy.sh
SERVER_IP="47.111.10.27"
USERNAME="root"
REMOTE_PATH="/data/www/emotion-museum"
echo "开始部署前端应用到服务器..."
# 检查dist目录是否存在
if [ ! -d "dist" ]; then
echo "错误: dist目录不存在,请先运行 npm run build"
exit 1
fi
# 检查是否安装了scp命令
if ! command -v scp &> /dev/null; then
echo "错误: 未找到scp命令,请安装OpenSSH客户端"
exit 1
fi
echo "正在上传文件到服务器 $SERVER_IP..."
# 上传所有文件到服务器
if scp dist/index.html "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/" && \
scp -r dist/assets "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/" && \
scp dist/test-*.html "${USERNAME}@${SERVER_IP}:${REMOTE_PATH}/"; then
echo "部署完成!"
echo "访问地址: http://$SERVER_IP/emotion-museum/"
else
echo "部署失败,请检查:"
echo "1. 服务器IP地址是否正确"
echo "2. SSH密钥是否配置正确"
echo "3. 服务器目录权限是否正确"
exit 1
fi
-56
View File
@@ -1,56 +0,0 @@
version: '3.8'
services:
# 开心APP前端服务
emotion-museum-web:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: emotion-museum-web
ports:
- "3000:80"
environment:
- NODE_ENV=production
volumes:
# 如果需要挂载配置文件
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- emotion-museum-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.emotion-web.rule=Host(`localhost`)"
- "traefik.http.services.emotion-web.loadbalancer.server.port=80"
# 开发模式服务(可选)
emotion-museum-web-dev:
image: node:18-alpine
container_name: emotion-museum-web-dev
working_dir: /app
ports:
- "3001:3000"
environment:
- NODE_ENV=development
volumes:
- .:/app
- /app/node_modules
command: sh -c "npm install && npm run dev"
networks:
- emotion-museum-network
profiles:
- dev
networks:
emotion-museum-network:
driver: bridge
name: emotion-museum-network
volumes:
node_modules:
+9 -6
View File
@@ -1,15 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png" />
<link rel="icon" type="image/svg+xml" href="https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Permissions-Policy" content="unload=()">
<title>开心APP - 你的情绪陪伴使者</title>
<meta name="description" content="开心APP是一款AI情绪陪伴应用,提供智能对话、情绪日记、个人展板等功能,陪伴你的每一个情绪时刻。" />
<meta name="keywords" content="AI助手,情绪陪伴,智能对话,情绪日记,心理健康" />
<meta name="description" content="开心APP - 你的情绪陪伴使者,记录、分析、分享你的情绪世界" />
<!-- Tailwind CSS 已通过本地安装配置 -->
<script src="https://unpkg.com/lucide@latest"></script>
<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;700&display=swap" rel="stylesheet">
</head>
<body>
<body class="bg-light-gray font-sans text-text-dark">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+5211 -4110
View File
File diff suppressed because it is too large Load Diff
+51 -34
View File
@@ -1,49 +1,66 @@
{
"name": "emotion-museum-web",
"version": "1.0.0",
"description": "开心APP - 情绪陪伴使者前端应用",
"description": "情绪博物馆Web系统",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"build:silent": "vue-tsc && vite build 2>&1 | findstr /v \"Deprecation Warning\"",
"build": "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",
"format": "prettier --write src/",
"type-check": "vue-tsc --noEmit"
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"test:e2e": "cypress run"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.0",
"ant-design-vue": "^4.0.0",
"axios": "^1.5.0",
"chart.js": "^4.3.0",
"dayjs": "^1.11.9",
"pinia": "^2.1.6",
"@element-plus/icons-vue": "^2.3.1",
"@headlessui/vue": "^1.7.16",
"@stomp/stompjs": "^7.1.1",
"@vueuse/core": "^10.7.0",
"autoprefixer": "^10.4.16",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"element-plus": "^2.4.4",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"postcss": "^8.4.32",
"socket.io-client": "^4.7.4",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"vue": "^3.3.4",
"vue-chartjs": "^5.2.0",
"vue-router": "^4.2.4"
"tailwindcss": "^3.4.0",
"vue": "^3.4.0",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.5.0",
"@types/sockjs-client": "^1.5.2",
"@types/stompjs": "^2.3.5",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.0",
"sass": "^1.89.2",
"typescript": "^5.1.0",
"vite": "^4.5.0",
"vue-tsc": "^3.0.4"
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.10.5",
"@types/sockjs-client": "^1.5.4",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/test-utils": "^2.4.3",
"cypress": "^13.6.2",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"husky": "^8.0.3",
"lint-staged": "^15.2.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"vite": "^5.0.8",
"vitest": "^1.1.0",
"vue-tsc": "^2.0.0"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
}
"keywords": [
"vue3",
"typescript",
"emotion",
"museum",
"ai-chat",
"data-visualization"
],
"author": "Emotion Museum Team",
"license": "MIT"
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+14 -129
View File
@@ -1,140 +1,25 @@
<template>
<div id="app">
<a-config-provider :theme="themeConfig">
<router-view />
</a-config-provider>
<div id="app" class="min-h-screen bg-light-gray">
<router-view />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAppStore, useUserStore } from '@/stores'
import { onMounted } from 'vue'
const appStore = useAppStore()
const userStore = useUserStore()
// Ant Design 主题配置
const themeConfig = computed(() => ({
token: {
colorPrimary: appStore.theme.primaryColor,
colorSuccess: '#52c41a',
colorWarning: appStore.theme.secondaryColor,
colorError: '#ff4d4f',
colorInfo: appStore.theme.primaryColor,
borderRadius: 8,
fontFamily: "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
components: {
Button: {
borderRadius: 20,
controlHeight: 40,
},
Input: {
borderRadius: 8,
controlHeight: 40,
},
Card: {
borderRadius: 12,
},
},
}))
onMounted(() => {
// 初始化应用
appStore.init()
userStore.initUser()
})
// 根组件逻辑
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>
<style lang="scss">
<style>
#app {
min-height: 100vh;
background-color: #f5f5f5;
}
/* 自定义Ant Design样式 */
.ant-btn {
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
}
&.ant-btn-primary {
background: linear-gradient(135deg, #4a90e2 0%, #5ba0f2 100%);
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: linear-gradient(135deg, #5ba0f2 0%, #6bb0ff 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
&.ant-btn-orange {
background: linear-gradient(135deg, #ff7849 0%, #ff8859 100%);
border: none;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: linear-gradient(135deg, #ff8859 0%, #ff9869 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
color: white;
}
}
}
.ant-card {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: none;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
}
.ant-input,
.ant-input-affix-wrapper {
border-radius: 12px;
border: 1px solid #e8e8e8;
transition: all 0.3s ease;
&:hover,
&:focus,
&.ant-input-affix-wrapper-focused {
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.1);
}
}
.ant-message {
.ant-message-notice-content {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
/* 滚动条美化 */
.ant-layout-content {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
font-family: 'Noto Sans SC', system-ui, sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
}
</style>
-256
View File
@@ -1,256 +0,0 @@
// 动画效果样式文件
/* 淡入向上动画 */
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-400 { animation-delay: 0.4s; }
.delay-500 { animation-delay: 0.5s; }
.delay-600 { animation-delay: 0.6s; }
.delay-700 { animation-delay: 0.7s; }
.delay-800 { animation-delay: 0.8s; }
.delay-900 { animation-delay: 0.9s; }
.delay-1000 { animation-delay: 1s; }
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 滚动触发动画 */
.scroll-target {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
&.visible {
opacity: 1;
transform: translateY(0);
}
}
/* 波浪动画 */
.wave {
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+");
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 147px;
animation: wave 15s linear infinite;
&:nth-child(2) {
animation-direction: reverse;
animation-duration: 20s;
opacity: 0.8;
}
&:nth-child(3) {
animation-duration: 25s;
opacity: 0.5;
}
}
@keyframes wave {
0% { transform: translateX(0); }
50% { transform: translateX(-50%); }
100% { transform: translateX(0); }
}
/* 悬停效果 */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
}
.hover-scale {
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
/* 按钮动画 */
.btn-bounce {
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px) scale(1.05);
}
&:active {
transform: translateY(0) scale(0.98);
}
}
/* 加载动画 */
.loading-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* 旋转动画 */
.loading-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 滑入动画 */
.slide-in-left {
animation: slide-in-left 0.5s ease-out;
}
.slide-in-right {
animation: slide-in-right 0.5s ease-out;
}
.slide-in-down {
animation: slide-in-down 0.3s ease-out;
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-in-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 弹性动画 */
.bounce-in {
animation: bounce-in 0.6s ease-out;
}
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* 渐变背景动画 */
.gradient-animation {
background: linear-gradient(-45deg, #4A90E2, #F5A623, #4A90E2, #F5A623);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 打字机效果 */
.typewriter {
overflow: hidden;
border-right: 0.15em solid #4A90E2;
white-space: nowrap;
margin: 0 auto;
letter-spacing: 0.15em;
animation: typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite;
}
@keyframes typing {
from {
width: 0;
}
to {
width: 100%;
}
}
@keyframes blink-caret {
from, to {
border-color: transparent;
}
50% {
border-color: #4A90E2;
}
}
/* 响应式动画控制 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
-153
View File
@@ -1,153 +0,0 @@
@use "@/assets/styles/variables.scss" as *;
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
/* 全局重置 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333333;
background-color: #f5f5f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 工具类 */
.text-tech-blue {
color: #4a90e2 !important;
}
.text-warm-orange {
color: #ff7849 !important;
}
.bg-tech-blue {
background-color: #4a90e2 !important;
}
.bg-warm-orange {
background-color: #ff7849 !important;
}
.bg-light-gray {
background-color: #f5f5f5 !important;
}
/* 动画类 */
.fade-in-up {
animation: fadeInUp 0.8s ease-out forwards;
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.scroll-target {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.scroll-target.visible {
opacity: 1;
transform: translateY(0);
}
/* 响应式工具类 */
.container {
width: 100%;
margin: 0 auto;
padding: 0 16px;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1536px;
}
}
/* Ant Design 主题覆盖 */
.ant-btn-primary {
background-color: #4a90e2;
border-color: #4a90e2;
}
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background-color: #5ba0f2;
border-color: #5ba0f2;
}
.ant-btn-orange {
background-color: #ff7849;
border-color: #ff7849;
color: white;
}
.ant-btn-orange:hover,
.ant-btn-orange:focus {
background-color: #ff8859;
border-color: #ff8859;
color: white;
}
-1
View File
@@ -1 +0,0 @@
@use "@/assets/styles/variables.scss" as *;
+215
View File
@@ -0,0 +1,215 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 原始CSS变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 全局样式 */
@layer base {
html {
font-family: 'Noto Sans SC', system-ui, sans-serif;
}
body {
@apply text-gray-900 bg-gray-50;
font-family: 'Noto Sans SC', sans-serif;
}
}
@layer components {
/* 自定义组件样式 */
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
}
.input-field {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
/* 原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
/* Header滚动样式 */
#main-header.scrolled {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border-bottom-color: #e5e7eb;
}
/* 功能卡片样式 */
.feature-card-bg {
background-color: var(--white);
border: 1px solid #e5e7eb;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.feature-card-bg:hover {
transform: translateY(-8px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.feature-card-image-container {
background-color: #eef5fe;
background-image: url('data:image/svg+xml;utf8,<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M-10 10 C 20 20, 40 0, 60 10 S 100 0, 120 10" stroke="%234A90E2" fill="none" stroke-width="2" stroke-opacity="0.2"/></svg>');
background-size: 50px;
background-repeat: repeat;
}
/* 动画样式 */
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
opacity: 0;
}
.scroll-target {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.scroll-target.visible {
opacity: 1;
transform: translateY(0);
}
/* 波浪动画 */
.wave {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiB2aWV3Qm94PSIwIDAgMTQ0MCAxNDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPmdyb3VwPC90aXRsZT48ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBpZD0iQ29tcG9uZW50LS0tV2F2ZS1Cb3R0b20iIGZpbGw9IiM0QTkwRTIiPjxwYXRoIGQ9Ik0wLDc0LjgzMjk0MTIgQzM2MCw3NC44MzI5NDEyIDM2MCwxNDcgNzIwLDE0NyBDMTA4MCwxNDcgMTA4MCw3NC44MzI5NDEyIDE0NDAsNzQuODMyOTQxMiBMMTQ0MCwxNDcgTDAsMTQ3IEwwLDc0LjgzMjk0MTIgWiIgaWQ9IldhdmUiIG9wYWNpdHk9IjAuMSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+);
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 147px;
animation: wave 15s linear infinite;
}
.wave:nth-of-type(2) {
animation-direction: reverse;
animation-duration: 20s;
opacity: 0.8;
}
.wave:nth-of-type(3) {
animation-duration: 25s;
opacity: 0.5;
}
/* 聊天消息样式 */
#chat-messages {
scrollbar-width: thin;
scrollbar-color: var(--tech-blue) var(--light-gray);
}
#chat-messages::-webkit-scrollbar {
width: 6px;
}
#chat-messages::-webkit-scrollbar-track {
background: var(--light-gray);
}
#chat-messages::-webkit-scrollbar-thumb {
background-color: var(--tech-blue);
border-radius: 10px;
border: 2px solid transparent;
background-clip: content-box;
}
.message-animate {
animation: message-fade-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
opacity: 0;
transform: translateY(10px);
}
/* 模态框样式 */
#login-modal:not(.hidden) {
animation: modal-fade-in 0.2s ease-out forwards;
}
#login-modal:not(.hidden) > div {
animation: modal-scale-up 0.2s ease-out forwards;
}
#topic-detail-modal.hidden {
display: none;
}
}
@layer utilities {
/* 自定义工具类 */
.text-gradient {
@apply bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent;
}
.shadow-soft {
box-shadow: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
}
}
/* 关键帧动画 */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wave {
0% { transform: translateX(0); }
50% { transform: translateX(-50%); }
100% { transform: translateX(0); }
}
@keyframes message-fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-scale-up {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
-58
View File
@@ -1,58 +0,0 @@
// 主题色彩
$tech-blue: #4A90E2;
$warm-orange: #F5A623;
$white: #FFFFFF;
$light-gray: #F7F8FA;
$text-dark: #333333;
$text-medium: #888888;
$border-color: #e8e8e8;
// 间距
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$spacing-xxl: 48px;
// 圆角
$border-radius-sm: 4px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
$border-radius-xl: 16px;
$border-radius-full: 9999px;
// 阴影
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
// 断点
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-xxl: 1536px;
// 字体大小
$font-size-xs: 12px;
$font-size-sm: 14px;
$font-size-md: 16px; // 添加缺失的 md 尺寸
$font-size-base: 16px;
$font-size-lg: 18px;
$font-size-xl: 20px;
$font-size-2xl: 24px;
$font-size-3xl: 30px;
$font-size-4xl: 36px;
// 字体权重
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// 过渡动画
$transition-fast: 0.15s ease-in-out;
$transition-normal: 0.3s ease-in-out;
$transition-slow: 0.5s ease-in-out;
+302
View File
@@ -0,0 +1,302 @@
<template>
<div
v-if="visible"
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
@click.self="handleClose"
>
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<!-- 弹框头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 class="text-2xl font-bold text-gray-900">聊天历史记录</h2>
<p class="text-sm text-gray-600 mt-1">查看和搜索您的所有对话记录</p>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 transition-colors p-2 rounded-full hover:bg-gray-100"
>
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
<!-- 搜索和筛选区域 -->
<div class="p-6 border-b border-gray-200 bg-gray-50">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 关键词搜索 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">搜索关键词</label>
<div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"></i>
<input
v-model="searchKeyword"
type="text"
placeholder="输入关键词..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@input="handleSearch"
/>
</div>
</div>
<!-- 日期范围 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始日期</label>
<input
v-model="startDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@change="handleDateFilter"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束日期</label>
<input
v-model="endDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@change="handleDateFilter"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
共找到 {{ totalCount }} 条记录
</div>
<div class="flex space-x-2">
<button
@click="clearFilters"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
清除筛选
</button>
<button
@click="refreshData"
:disabled="loading"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
</div>
</div>
<!-- 消息列表 -->
<div class="flex-1 overflow-y-auto p-6">
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-gray-600">加载中...</p>
</div>
<div v-else-if="messages.length === 0" class="text-center py-8 text-gray-500">
<i data-lucide="message-circle" class="w-12 h-12 mx-auto mb-4 text-gray-300"></i>
<p>暂无聊天记录</p>
</div>
<div v-else class="space-y-4">
<div
v-for="message in messages"
:key="message.id"
class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:class="message.sender === 'user' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'"
>
{{ message.sender === 'user' ? '我' : '开开' }}
</span>
<span class="text-xs text-gray-500">
{{ formatDateTime(message.createTime) }}
</span>
</div>
<p class="text-gray-900 leading-relaxed">{{ message.content }}</p>
<div v-if="message.aiReply" class="mt-2 p-3 bg-white rounded border-l-4 border-green-400">
<p class="text-sm text-gray-700">{{ message.aiReply }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="p-6 border-t border-gray-200">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-600">
{{ currentPage }} {{ totalPages }}
</div>
<div class="flex space-x-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import MessageService, { type MessageResponse, type PageResult } from '@/services/message'
interface Props {
visible: boolean
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 响应式数据
const loading = ref(false)
const messages = ref<MessageResponse[]>([])
const searchKeyword = ref('')
const startDate = ref('')
const endDate = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value))
// 搜索防抖
let searchTimeout: NodeJS.Timeout | null = null
// 方法
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
const loadMessages = async () => {
try {
loading.value = true
if (searchKeyword.value.trim()) {
// 搜索模式
const searchResult = await MessageService.searchUserMessages({
keyword: searchKeyword.value.trim(),
limit: 100,
startTime: startDate.value ? `${startDate.value} 00:00:00` : undefined,
endTime: endDate.value ? `${endDate.value} 23:59:59` : undefined
})
messages.value = searchResult
totalCount.value = searchResult.length
currentPage.value = 1
} else {
// 分页模式
const pageResult = await MessageService.getUserMessages(currentPage.value, pageSize.value)
messages.value = pageResult.records
totalCount.value = pageResult.total
}
} catch (error) {
console.error('加载消息失败:', error)
ElMessage.error('加载聊天记录失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
loadMessages()
}, 500)
}
const handleDateFilter = () => {
currentPage.value = 1
loadMessages()
}
const clearFilters = () => {
searchKeyword.value = ''
startDate.value = ''
endDate.value = ''
currentPage.value = 1
loadMessages()
}
const refreshData = () => {
loadMessages()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadMessages()
}
}
const formatDateTime = (dateTime: string) => {
return new Date(dateTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 监听弹框显示状态
watch(() => props.visible, (newVal) => {
if (newVal) {
loadMessages()
// 初始化图标
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
}, 100)
}
})
onMounted(() => {
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>
<style scoped>
/* 自定义滚动条 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="user-avatar" :class="sizeClass">
<img
v-if="avatar"
:src="avatar"
:alt="nickname"
class="avatar-image"
@error="handleImageError"
/>
<div v-else class="avatar-placeholder">
{{ avatarText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Props {
/** 头像URL */
avatar?: string
/** 昵称 */
nickname: string
/** 尺寸 */
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium'
})
// 图片加载失败标记
const imageError = ref(false)
// 尺寸类名
const sizeClass = computed(() => `avatar-${props.size}`)
// 头像文字(取昵称首字符)
const avatarText = computed(() => {
if (!props.nickname) return '用'
return props.nickname.charAt(0).toUpperCase()
})
// 处理图片加载失败
const handleImageError = () => {
imageError.value = true
}
</script>
<style scoped>
.user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
flex-shrink: 0;
}
.avatar-small {
width: 32px;
height: 32px;
font-size: 14px;
}
.avatar-medium {
width: 40px;
height: 40px;
font-size: 16px;
}
.avatar-large {
width: 64px;
height: 64px;
font-size: 24px;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info-trigger">
<UserAvatar :avatar="userInfo?.avatar" :nickname="userInfo?.nickname || '用户'" size="medium" />
<div class="user-text">
<div class="user-nickname">{{ userInfo?.nickname || '用户' }}</div>
<div class="user-level">{{ userInfo?.memberLevel || 'Lv.1' }}</div>
</div>
<el-icon class="dropdown-icon">
<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 command="dashboard">
<el-icon><DataBoard /></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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
ArrowDown,
User,
Setting,
DataBoard,
SwitchButton
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import UserAvatar from './UserAvatar.vue'
const router = useRouter()
const authStore = useAuthStore()
// 用户信息
const userInfo = computed(() => authStore.userInfo)
// 处理下拉菜单命令
const handleCommand = async (command: string) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'dashboard':
router.push('/personal-dashboard')
break
case 'logout':
await handleLogout()
break
}
}
// 处理退出登录
const handleLogout = async () => {
try {
await ElMessageBox.confirm(
'确定要退出登录吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await authStore.logout()
router.push('/')
} catch (error) {
// 用户取消操作
}
}
</script>
<style scoped>
.user-info-trigger {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
transition: all 0.3s ease;
color: white;
}
.user-info-trigger:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.user-text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.user-nickname {
font-size: 14px;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
.user-level {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
line-height: 1;
}
.dropdown-icon {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
transition: transform 0.3s ease;
}
.user-info-trigger:hover .dropdown-icon {
transform: translateY(1px);
}
:deep(.el-dropdown-menu) {
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
}
:deep(.el-dropdown-menu__item) {
padding: 12px 16px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
:deep(.el-dropdown-menu__item:hover) {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
:deep(.el-dropdown-menu__item.is-divided) {
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin-top: 4px;
}
/* 响应式设计 */
@media (max-width: 640px) {
.user-text {
display: none;
}
.user-info-trigger {
padding: 6px;
gap: 0;
}
}
</style>
-34
View File
@@ -1,34 +0,0 @@
<template>
<footer class="app-footer">
<div style="background: white; padding: 40px 20px; text-align: center; border-top: 1px solid #e8e8e8;">
<div style="max-width: 1200px; margin: 0 auto;">
<div style="margin-bottom: 20px;">
<h3 style="color: #4A90E2; font-size: 20px; margin-bottom: 8px;">开心APP</h3>
<p style="color: #888; margin: 0;">陪伴理解记录共同成长</p>
</div>
<div style="display: flex; justify-content: center; gap: 40px; margin-bottom: 20px; flex-wrap: wrap;">
<router-link to="/chat" style="color: #888; text-decoration: none;">聊天</router-link>
<router-link to="/diary" style="color: #888; text-decoration: none;">日记</router-link>
<router-link to="/dashboard" style="color: #888; text-decoration: none;">展板</router-link>
<router-link to="/settings" style="color: #888; text-decoration: none;">设置</router-link>
</div>
<p style="color: #888; font-size: 14px; margin: 0;">
© 2025 开心APP. All Rights Reserved. 来自"开心"星球的温柔科技
</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
// 简化版Footer组件
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.app-footer {
margin-top: auto;
}
</style>
-321
View File
@@ -1,321 +0,0 @@
<template>
<header class="app-header" :class="{ 'scrolled': isScrolled }">
<div class="header-content">
<!-- Logo -->
<router-link to="/" class="logo">
<svg width="32" height="32" viewBox="0 0 100 100" class="logo-icon">
<path fill="currentColor" d="M85.4,37.3C85.4,37.3,85.4,37.3,85.4,37.3c-2.8-9.9-10-17.7-19.1-21.2c-0.2-0.1-0.5-0.1-0.7-0.2c-0.1,0-0.2-0.1-0.3-0.1 c-1.2-0.4-2.5-0.8-3.7-1.1c-1-0.2-2-0.4-3-0.6c-1.1-0.2-2.1-0.3-3.2-0.4c-1.2-0.1-2.4-0.2-3.6-0.2c-0.1,0-0.2,0-0.3,0h-0.1 c-0.1,0-0.2,0-0.3,0c-1.2,0-2.4,0.1-3.6,0.2c-1.1,0.1-2.1,0.2-3.2,0.4c-1,0.2-2,0.4-3,0.6c-1.3,0.3-2.5,0.6-3.7,1.1 c-0.1,0-0.2,0.1-0.3,0.1c-0.2,0.1-0.5,0.1-0.7,0.2C21.6,19.6,14.4,27.4,11.6,37.3c0,0,0,0.1-0.1,0.1C8,47.7,8,58.8,11.5,69.2 c0,0.1,0.1,0.1,0.1,0.2c2.8,9.9,10,17.7,19.1,21.2c0.2,0.1,0.5,0.1,0.7,0.2c0.1,0,0.2,0.1,0.3,0.1c1.2,0.4,2.5,0.8,3.7,1.1 c1,0.2,2,0.4,3,0.6c1.1,0.2,2.1,0.3,3.2,0.4c1.2,0.1,2.4,0.2,3.6,0.2c0.1,0,0.2,0,0.3,0h0.1c0.1,0,0.2,0,0.3,0 c1.2,0,2.4-0.1,3.6-0.2c-1.1-0.1-2.1-0.2-3.2-0.4c1-0.2,2-0.4,3-0.6c1.3-0.3,2.5-0.6,3.7-1.1c0.1,0,0.2-0.1,0.3-0.1 c0.2-0.1,0.5-0.1,0.7-0.2c9.1-3.5,16.3-11.3,19.1-21.2c0-0.1,0.1-0.1,0.1-0.2C89,58.8,89,47.7,85.4,37.3z M50,77.9 c-15.4,0-27.9-12.5-27.9-27.9S34.6,22.1,50,22.1s27.9,12.5,27.9,27.9S65.4,77.9,50,77.9z"></path>
<path fill="#F5A623" d="M50,88.8c-21.4,0-38.8-17.4-38.8-38.8S28.6,11.2,50,11.2s38.8,17.4,38.8,38.8S71.4,88.8,50,88.8z M50,16.2 c-18.7,0-33.8,15.1-33.8,33.8S31.3,83.8,50,83.8s33.8-15.1,33.8-33.8S68.7,16.2,50,16.2z"></path>
</svg>
<span class="logo-text">开心APP</span>
</router-link>
<!-- 导航菜单 -->
<nav class="nav-menu" :class="{ 'mobile-hidden': !mobileMenuVisible }">
<router-link to="/chat" class="nav-link" @click="closeMobileMenu">聊天</router-link>
<router-link to="/diary" class="nav-link" @click="closeMobileMenu">日记</router-link>
<router-link to="/dashboard" class="nav-link" @click="closeMobileMenu">展板</router-link>
<router-link to="/topic-tracker" class="nav-link" @click="closeMobileMenu">话题追踪</router-link>
</nav>
<!-- 右侧操作区 -->
<div class="header-actions">
<!-- 未登录状态 -->
<template v-if="!userStore.isLoggedIn">
<a-button type="text" @click="$router.push('/login')" class="login-btn">
登录
</a-button>
<a-button type="primary" @click="$router.push('/chat')" class="start-btn">
免费开始
</a-button>
</template>
<!-- 已登录状态 -->
<template v-else>
<a-dropdown>
<div class="user-info-section">
<a-avatar
:size="32"
:src="userStore.userInfo?.avatar"
class="user-avatar"
>
<template #icon v-if="!userStore.userInfo?.avatar">
<UserOutlined />
</template>
</a-avatar>
<span class="user-nickname">
{{ userStore.userInfo?.nickname || userStore.userInfo?.username || '用户' }}
</span>
<DownOutlined class="dropdown-icon" />
</div>
<template #overlay>
<a-menu>
<a-menu-item key="profile" @click="$router.push('/dashboard')">
<UserOutlined />
个人中心
</a-menu-item>
<a-menu-item key="settings" @click="$router.push('/settings')">
<SettingOutlined />
设置
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- 移动端菜单按钮 -->
<a-button
type="text"
class="mobile-menu-btn"
@click="toggleMobileMenu"
>
<MenuOutlined v-if="!mobileMenuVisible" />
<CloseOutlined v-else />
</a-button>
</div>
</div>
<!-- 移动端菜单 -->
<div v-if="mobileMenuVisible" class="mobile-menu">
<nav class="mobile-nav">
<router-link to="/chat" class="mobile-nav-link" @click="closeMobileMenu">聊天</router-link>
<router-link to="/diary" class="mobile-nav-link" @click="closeMobileMenu">日记</router-link>
<router-link to="/dashboard" class="mobile-nav-link" @click="closeMobileMenu">展板</router-link>
<router-link to="/topic-tracker" class="mobile-nav-link" @click="closeMobileMenu">话题追踪</router-link>
</nav>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
UserOutlined,
DownOutlined,
SettingOutlined,
LogoutOutlined,
MenuOutlined,
CloseOutlined
} from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// 响应式状态
const isScrolled = ref(false)
const mobileMenuVisible = ref(false)
// 滚动监听
const handleScroll = () => {
isScrolled.value = window.scrollY > 50
}
// 移动端菜单控制
const toggleMobileMenu = () => {
mobileMenuVisible.value = !mobileMenuVisible.value
}
const closeMobileMenu = () => {
mobileMenuVisible.value = false
}
// 退出登录
const handleLogout = async () => {
try {
await userStore.logout()
message.success('退出登录成功')
router.push('/')
} catch (error) {
message.error('退出登录失败')
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
handleScroll() // 初始检查
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(16px);
border-bottom: 1px solid transparent;
transition: all 0.3s ease;
&.scrolled {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border-bottom-color: #e5e7eb;
}
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
max-width: 1200px;
margin: 0 auto;
@media (max-width: 768px) {
padding: 12px 16px;
}
}
.logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: $tech-blue;
font-weight: 700;
font-size: 24px;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
@media (max-width: 768px) {
font-size: 20px;
}
}
.logo-icon {
color: $tech-blue;
flex-shrink: 0;
@media (max-width: 768px) {
width: 28px;
height: 28px;
}
}
.logo-text {
@media (max-width: 480px) {
display: none;
}
}
.nav-menu {
display: flex;
align-items: center;
gap: 32px;
}
.nav-link {
color: #888888;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease-in-out;
&:hover {
color: #4A90E2;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.login-btn {
color: #4A90E2;
font-weight: 500;
&:hover {
color: #4A90E2;
background: rgba(74, 144, 226, 0.1);
}
}
.user-info-section {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(74, 144, 226, 0.1);
}
.user-avatar {
border: 2px solid #f0f0f0;
}
.user-nickname {
font-weight: 500;
color: #4A90E2;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-icon {
font-size: 12px;
color: #8c8c8c;
margin-left: 4px;
}
}
.user-btn {
color: #4A90E2;
font-weight: 500;
&:hover {
color: #4A90E2;
background: rgba(74, 144, 226, 0.1);
}
}
.start-btn {
border-radius: 20px;
font-weight: 600;
}
// 响应式设计
@media (max-width: 768px) {
.header-content {
padding: 0 16px;
}
.nav-menu {
display: none;
}
.user-info-section {
.user-nickname {
display: none;
}
}
.header-actions {
gap: 8px;
}
}
</style>
@@ -0,0 +1,51 @@
<template>
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-sm shadow-[0_-2px_10px_rgba(0,0,0,0.05)] flex justify-around py-2 border-t border-gray-200/80">
<router-link
v-for="item in navItems"
:key="item.name"
:to="item.href"
class="flex flex-col items-center justify-center text-xs p-2 rounded-md transition-colors w-20"
:class="isActive(item.href) ? 'text-tech-blue bg-tech-blue/10 font-semibold' : 'text-text-medium hover:bg-gray-100 hover:text-tech-blue'"
>
<i :data-lucide="item.icon" class="w-5 h-5 mb-1"></i>
<span>{{ item.text }}</span>
</router-link>
</nav>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const navItems = [
{ icon: 'message-square', text: '聊天', href: '/chat', name: 'Chat' },
{ icon: 'book-open', text: '日记', href: '/diary', name: 'Diary' },
{ icon: 'crosshair', text: '话题', href: '/topic-tracker', name: 'TopicTracker' },
{ icon: 'milestone', text: '人生轨迹', href: '/life-milestones', name: 'LifeMilestones' },
{ icon: 'layout-dashboard', text: '个人展板', href: '/personal-dashboard', name: 'PersonalDashboard' }
]
const isActive = (href: string) => {
return route.path === href
}
onMounted(() => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>
<style scoped>
:root {
--tech-blue: #4A90E2;
--text-medium: #888888;
}
.text-tech-blue { color: var(--tech-blue); }
.text-text-medium { color: var(--text-medium); }
.bg-tech-blue\/10 { background-color: rgba(74, 144, 226, 0.1); }
</style>
+261
View File
@@ -0,0 +1,261 @@
/**
* 认证相关组合式函数
*/
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import AuthService from '@/services/auth'
import type { LoginRequest, RegisterRequest, CaptchaResponse } from '@/types/auth'
/**
* 使用认证功能
*/
export const useAuth = () => {
const router = useRouter()
const authStore = useAuthStore()
// 加载状态
const loading = ref(false)
// 验证码相关
const captchaData = ref<CaptchaResponse | null>(null)
const captchaImage = computed(() =>
captchaData.value ? `data:image/png;base64,${captchaData.value.captchaImage}` : ''
)
/**
* 获取验证码
*/
const getCaptcha = async () => {
try {
const response = await AuthService.getCaptcha()
captchaData.value = response
return response
} catch (error) {
console.error('获取验证码失败:', error)
ElMessage.error('获取验证码失败')
throw error
}
}
/**
* 刷新验证码
*/
const refreshCaptcha = async () => {
return getCaptcha()
}
/**
* 登录
*/
const login = async (loginData: LoginRequest) => {
try {
loading.value = true
const success = await authStore.login(loginData)
if (success) {
ElMessage.success('登录成功')
return true
}
return false
} catch (error: any) {
console.error('登录失败:', error)
ElMessage.error(error.message || '登录失败')
return false
} finally {
loading.value = false
}
}
/**
* 注册
*/
const register = async (registerData: RegisterRequest) => {
try {
loading.value = true
const success = await authStore.register(registerData)
if (success) {
ElMessage.success('注册成功')
return true
}
return false
} catch (error: any) {
console.error('注册失败:', error)
ElMessage.error(error.message || '注册失败')
return false
} finally {
loading.value = false
}
}
/**
* 登出
*/
const logout = async () => {
try {
await authStore.logout()
router.push('/login')
} catch (error) {
console.error('登出失败:', error)
}
}
/**
* 检查账号是否存在
*/
const checkAccountExists = async (account: string) => {
if (!account || !/^[a-zA-Z0-9_]{4,20}$/.test(account)) {
return false
}
try {
return await AuthService.checkAccountExists(account)
} catch (error) {
console.error('检查账号失败:', error)
return false
}
}
/**
* 检查邮箱是否存在
*/
const checkEmailExists = async (email: string) => {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return false
}
try {
return await AuthService.checkEmailExists(email)
} catch (error) {
console.error('检查邮箱失败:', error)
return false
}
}
/**
* 检查手机号是否存在
*/
const checkPhoneExists = async (phone: string) => {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
return false
}
try {
return await AuthService.checkPhoneExists(phone)
} catch (error) {
console.error('检查手机号失败:', error)
return false
}
}
return {
// 状态
loading,
captchaData,
captchaImage,
// 计算属性
isLoggedIn: authStore.isLoggedIn,
userInfo: authStore.userInfo,
// 方法
getCaptcha,
refreshCaptcha,
login,
register,
logout,
checkAccountExists,
checkEmailExists,
checkPhoneExists
}
}
/**
* 使用表单验证
*/
export const useFormValidation = () => {
/**
* 账号验证规则
*/
const validateAccount = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请输入账号'))
return
}
if (!/^[a-zA-Z0-9_]{4,20}$/.test(value)) {
callback(new Error('账号只能包含字母、数字和下划线,长度4-20位'))
return
}
callback()
}
/**
* 密码验证规则
*/
const validatePassword = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请输入密码'))
return
}
if (value.length < 6 || value.length > 20) {
callback(new Error('密码长度必须在6-20位之间'))
return
}
callback()
}
/**
* 确认密码验证规则
*/
const validateConfirmPassword = (password: string) => {
return (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请再次输入密码'))
return
}
if (value !== password) {
callback(new Error('两次输入的密码不一致'))
return
}
callback()
}
}
/**
* 邮箱验证规则
*/
const validateEmail = (rule: any, value: string, callback: any) => {
if (!value) {
callback(new Error('请输入邮箱地址'))
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
callback(new Error('请输入正确的邮箱格式'))
return
}
callback()
}
/**
* 手机号验证规则
*/
const validatePhone = (rule: any, value: string, callback: any) => {
if (value && !/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号格式'))
return
}
callback()
}
return {
validateAccount,
validatePassword,
validateConfirmPassword,
validateEmail,
validatePhone
}
}
+311
View File
@@ -0,0 +1,311 @@
/**
* 表单验证组合式函数
*/
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
/**
* 使用表单验证
*/
export const useFormValidation = <T extends Record<string, any>>(
initialData: T,
rules: FormRules
) => {
const formRef = ref<FormInstance>()
const formData = reactive<T>({ ...initialData })
const errors = ref<Record<string, string>>({})
const isValidating = ref(false)
/**
* 验证整个表单
*/
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false
try {
isValidating.value = true
await formRef.value.validate()
errors.value = {}
return true
} catch (error) {
console.error('表单验证失败:', error)
return false
} finally {
isValidating.value = false
}
}
/**
* 验证指定字段
*/
const validateField = async (field: keyof T): Promise<boolean> => {
if (!formRef.value) return false
try {
await formRef.value.validateField(field as string)
delete errors.value[field as string]
return true
} catch (error) {
errors.value[field as string] = error as string
return false
}
}
/**
* 清除验证结果
*/
const clearValidation = (fields?: (keyof T)[]) => {
if (!formRef.value) return
if (fields) {
formRef.value.clearValidate(fields as string[])
fields.forEach(field => {
delete errors.value[field as string]
})
} else {
formRef.value.clearValidate()
errors.value = {}
}
}
/**
* 重置表单
*/
const resetForm = () => {
if (!formRef.value) return
formRef.value.resetFields()
Object.assign(formData, initialData)
errors.value = {}
}
/**
* 设置字段错误
*/
const setFieldError = (field: keyof T, message: string) => {
errors.value[field as string] = message
}
/**
* 清除字段错误
*/
const clearFieldError = (field: keyof T) => {
delete errors.value[field as string]
}
/**
* 获取字段错误
*/
const getFieldError = (field: keyof T) => {
return errors.value[field as string]
}
/**
* 检查表单是否有错误
*/
const hasErrors = computed(() => {
return Object.keys(errors.value).length > 0
})
/**
* 检查表单是否有效
*/
const isValid = computed(() => {
return !hasErrors.value && !isValidating.value
})
return {
formRef,
formData,
errors: computed(() => errors.value),
isValidating: computed(() => isValidating.value),
hasErrors,
isValid,
validateForm,
validateField,
clearValidation,
resetForm,
setFieldError,
clearFieldError,
getFieldError
}
}
/**
* 常用验证规则
*/
export const validationRules = {
/**
* 必填验证
*/
required: (message = '此字段为必填项') => ({
required: true,
message,
trigger: 'blur'
}),
/**
* 邮箱验证
*/
email: (message = '请输入正确的邮箱格式') => ({
type: 'email' as const,
message,
trigger: 'blur'
}),
/**
* 手机号验证
*/
phone: (message = '请输入正确的手机号格式') => ({
pattern: /^1[3-9]\d{9}$/,
message,
trigger: 'blur'
}),
/**
* 长度验证
*/
length: (min: number, max: number, message?: string) => ({
min,
max,
message: message || `长度必须在${min}-${max}位之间`,
trigger: 'blur'
}),
/**
* 最小长度验证
*/
minLength: (min: number, message?: string) => ({
min,
message: message || `长度不能少于${min}`,
trigger: 'blur'
}),
/**
* 最大长度验证
*/
maxLength: (max: number, message?: string) => ({
max,
message: message || `长度不能超过${max}`,
trigger: 'blur'
}),
/**
* 正则验证
*/
pattern: (pattern: RegExp, message: string) => ({
pattern,
message,
trigger: 'blur'
}),
/**
* 自定义验证
*/
custom: (validator: (rule: any, value: any, callback: any) => void) => ({
validator,
trigger: 'blur'
}),
/**
* 账号验证(字母数字下划线)
*/
account: (message = '账号只能包含字母、数字和下划线,长度4-20位') => ({
pattern: /^[a-zA-Z0-9_]{4,20}$/,
message,
trigger: 'blur'
}),
/**
* 密码验证
*/
password: (min = 6, max = 20, message?: string) => ({
min,
max,
message: message || `密码长度必须在${min}-${max}位之间`,
trigger: 'blur'
}),
/**
* 确认密码验证
*/
confirmPassword: (passwordField: string, message = '两次输入的密码不一致') => ({
validator: (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请再次输入密码'))
return
}
// 这里需要访问表单数据,在实际使用时需要传入表单数据
callback()
},
trigger: 'blur'
})
}
/**
* 使用实时验证
*/
export const useRealtimeValidation = <T extends Record<string, any>>(
formData: T,
rules: FormRules
) => {
const errors = ref<Record<string, string>>({})
const validFields = ref<Set<string>>(new Set())
/**
* 验证单个字段
*/
const validateField = async (field: keyof T, value: any) => {
const fieldRules = rules[field as string]
if (!fieldRules) return true
try {
// 这里简化处理,实际应该使用async-validator
const ruleArray = Array.isArray(fieldRules) ? fieldRules : [fieldRules]
for (const rule of ruleArray) {
if (rule.required && (!value || value === '')) {
throw new Error(rule.message || '此字段为必填项')
}
if (rule.min && value && value.length < rule.min) {
throw new Error(rule.message || `长度不能少于${rule.min}`)
}
if (rule.max && value && value.length > rule.max) {
throw new Error(rule.message || `长度不能超过${rule.max}`)
}
if (rule.pattern && value && !rule.pattern.test(value)) {
throw new Error(rule.message || '格式不正确')
}
}
delete errors.value[field as string]
validFields.value.add(field as string)
return true
} catch (error: any) {
errors.value[field as string] = error.message
validFields.value.delete(field as string)
return false
}
}
/**
* 检查所有字段是否有效
*/
const isAllValid = computed(() => {
const requiredFields = Object.keys(rules)
return requiredFields.every(field => validFields.value.has(field)) &&
Object.keys(errors.value).length === 0
})
return {
errors: computed(() => errors.value),
validFields: computed(() => validFields.value),
isAllValid,
validateField
}
}
+269
View File
@@ -0,0 +1,269 @@
/**
* 加载状态管理组合式函数
*/
import { ref, computed } from 'vue'
import { ElLoading } from 'element-plus'
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
// 全局加载状态
const globalLoading = ref(false)
const loadingCount = ref(0)
/**
* 使用加载状态
*/
export const useLoading = (initialState = false) => {
const loading = ref(initialState)
/**
* 设置加载状态
*/
const setLoading = (state: boolean) => {
loading.value = state
}
/**
* 开始加载
*/
const startLoading = () => {
loading.value = true
}
/**
* 结束加载
*/
const stopLoading = () => {
loading.value = false
}
/**
* 异步操作包装器
*/
const withLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
startLoading()
return await fn()
} finally {
stopLoading()
}
}
return {
loading: computed(() => loading.value),
setLoading,
startLoading,
stopLoading,
withLoading
}
}
/**
* 使用全局加载状态
*/
export const useGlobalLoading = () => {
/**
* 增加加载计数
*/
const addLoading = () => {
loadingCount.value++
globalLoading.value = true
}
/**
* 减少加载计数
*/
const removeLoading = () => {
loadingCount.value = Math.max(0, loadingCount.value - 1)
if (loadingCount.value === 0) {
globalLoading.value = false
}
}
/**
* 重置加载状态
*/
const resetLoading = () => {
loadingCount.value = 0
globalLoading.value = false
}
/**
* 全局异步操作包装器
*/
const withGlobalLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
addLoading()
return await fn()
} finally {
removeLoading()
}
}
return {
globalLoading: computed(() => globalLoading.value),
loadingCount: computed(() => loadingCount.value),
addLoading,
removeLoading,
resetLoading,
withGlobalLoading
}
}
/**
* 使用页面加载遮罩
*/
export const usePageLoading = () => {
let loadingInstance: LoadingInstance | null = null
/**
* 显示页面加载遮罩
*/
const showPageLoading = (options?: {
text?: string
background?: string
target?: string | HTMLElement
}) => {
const {
text = '加载中...',
background = 'rgba(0, 0, 0, 0.7)',
target = 'body'
} = options || {}
loadingInstance = ElLoading.service({
lock: true,
text,
background,
target
})
}
/**
* 隐藏页面加载遮罩
*/
const hidePageLoading = () => {
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
/**
* 页面异步操作包装器
*/
const withPageLoading = async <T>(
fn: () => Promise<T>,
options?: {
text?: string
background?: string
target?: string | HTMLElement
}
): Promise<T> => {
try {
showPageLoading(options)
return await fn()
} finally {
hidePageLoading()
}
}
return {
showPageLoading,
hidePageLoading,
withPageLoading
}
}
/**
* 使用按钮加载状态
*/
export const useButtonLoading = () => {
const buttonLoadings = ref<Record<string, boolean>>({})
/**
* 设置按钮加载状态
*/
const setButtonLoading = (key: string, loading: boolean) => {
buttonLoadings.value[key] = loading
}
/**
* 获取按钮加载状态
*/
const getButtonLoading = (key: string) => {
return computed(() => buttonLoadings.value[key] || false)
}
/**
* 按钮异步操作包装器
*/
const withButtonLoading = async <T>(
key: string,
fn: () => Promise<T>
): Promise<T> => {
try {
setButtonLoading(key, true)
return await fn()
} finally {
setButtonLoading(key, false)
}
}
return {
buttonLoadings: computed(() => buttonLoadings.value),
setButtonLoading,
getButtonLoading,
withButtonLoading
}
}
/**
* 使用延迟加载
*/
export const useDelayedLoading = (delay = 300) => {
const loading = ref(false)
const actualLoading = ref(false)
let timer: NodeJS.Timeout | null = null
/**
* 设置加载状态(带延迟)
*/
const setLoading = (state: boolean) => {
if (state) {
// 立即设置实际加载状态
actualLoading.value = true
// 延迟显示加载UI
timer = setTimeout(() => {
loading.value = true
}, delay)
} else {
// 立即隐藏加载UI
if (timer) {
clearTimeout(timer)
timer = null
}
loading.value = false
actualLoading.value = false
}
}
/**
* 延迟异步操作包装器
*/
const withDelayedLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
setLoading(true)
return await fn()
} finally {
setLoading(false)
}
}
return {
loading: computed(() => loading.value),
actualLoading: computed(() => actualLoading.value),
setLoading,
withDelayedLoading
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 环境配置
* 根据不同环境加载对应的配置文件
*/
// 环境类型
export type EnvType = 'local' | 'dev' | 'test' | 'prod'
// 环境配置接口
export interface EnvConfig {
// 环境名称
name: string
// API基础URL
apiBaseUrl: string
// WebSocket URL
wsBaseUrl: string
// 文件上传URL
uploadUrl: string
// 是否开启调试模式
debug: boolean
// 是否开启mock
mock: boolean
// 应用标题
appTitle: string
// 应用版本
appVersion: string
}
// 获取当前环境
export const getCurrentEnv = (): EnvType => {
// 从环境变量获取,默认为local
const env = import.meta.env.VITE_APP_ENV as EnvType
return env || 'local'
}
// 获取环境配置
export const getEnvConfig = (): EnvConfig => {
const env = getCurrentEnv()
// 调试信息:打印所有环境变量
console.log('当前环境:', env)
console.log('所有环境变量:', import.meta.env)
// 优先使用环境变量,如果没有则使用默认值
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
const wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
const uploadUrl = import.meta.env.VITE_UPLOAD_URL
const debug = import.meta.env.VITE_DEBUG === 'true'
const mock = import.meta.env.VITE_MOCK === 'true'
const appTitle = import.meta.env.VITE_APP_TITLE
const appVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
console.log('环境变量解析结果:', {
apiBaseUrl,
wsBaseUrl,
uploadUrl,
debug,
mock,
appTitle,
appVersion
})
// 如果环境变量存在,直接使用
if (apiBaseUrl) {
return {
name: getEnvironmentName(env),
apiBaseUrl,
wsBaseUrl: wsBaseUrl || apiBaseUrl.replace('http', 'ws').replace('/api', ''),
uploadUrl: uploadUrl || `${apiBaseUrl}/upload`,
debug,
mock,
appTitle: appTitle || `情绪博物馆 - ${getEnvironmentName(env)}`,
appVersion
}
}
// 如果没有环境变量,使用默认配置
switch (env) {
case 'local':
return {
name: '本地环境',
apiBaseUrl: 'http://localhost:19089/api',
wsBaseUrl: 'ws://localhost:19089/api',
uploadUrl: 'http://localhost:19089/api/upload',
debug: true,
mock: false,
appTitle: '情绪博物馆 - 本地',
appVersion: '1.0.0'
}
case 'dev':
return {
name: '开发环境',
apiBaseUrl: 'http://localhost:19089/api',
wsBaseUrl: 'ws://localhost:19089/api',
uploadUrl: 'http://localhost:19089/api/upload',
debug: true,
mock: false,
appTitle: '情绪博物馆 - 开发',
appVersion: '1.0.0'
}
case 'test':
return {
name: '测试环境',
apiBaseUrl: 'http://test.emotion-museum.com/api',
wsBaseUrl: 'ws://test.emotion-museum.com',
uploadUrl: 'http://test.emotion-museum.com/api/upload',
debug: false,
mock: false,
appTitle: '情绪博物馆 - 测试',
appVersion: '1.0.0'
}
case 'prod':
return {
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'
}
default:
throw new Error(`未知的环境类型: ${env}`)
}
}
// 获取环境名称
const getEnvironmentName = (env: EnvType): string => {
switch (env) {
case 'local': return '本地环境'
case 'dev': return '开发环境'
case 'test': return '测试环境'
case 'prod': return '生产环境'
default: return '未知环境'
}
}
// 导出当前环境配置
export const envConfig = getEnvConfig()
// 导出常用配置
export const {
apiBaseUrl,
wsBaseUrl,
uploadUrl,
debug,
mock,
appTitle,
appVersion
} = envConfig
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+19 -12
View File
@@ -1,22 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import { useAuthStore } from './stores/auth'
// Ant Design Vue
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import './assets/styles/index.css'
// 全局样式
import '@/assets/styles/global.scss'
// 创建应用实例
const app = createApp(App)
const pinia = createPinia()
// 使用插件
app.use(pinia)
app.use(router)
app.use(Antd)
app.use(ElementPlus, {
locale: zhCn,
})
// 挂载应用
app.mount('#app')
// 初始化认证状态
const authStore = useAuthStore()
authStore.initAuth().then(() => {
app.mount('#app')
}).catch((error) => {
console.error('初始化认证状态失败:', error)
app.mount('#app')
})
+270
View File
@@ -0,0 +1,270 @@
/**
* 路由守卫
*/
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { envConfig } from '@/config/env'
// 不需要登录的路由白名单
const whiteList = [
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/',
'/404',
'/403',
'/500'
]
// 需要登录的路由
const authRequiredRoutes = [
'/chat',
'/chat-history',
'/diary',
'/life-milestones',
'/life-trajectory',
'/messages',
'/personal-dashboard',
'/settings',
'/topic-tracker',
'/emotion',
'/map',
'/social',
'/analysis',
'/profile'
]
/**
* 检查路由是否需要认证
*/
const requiresAuth = (path: string): boolean => {
return authRequiredRoutes.some(route => path.startsWith(route))
}
/**
* 检查路由是否在白名单中
*/
const isInWhiteList = (path: string): boolean => {
return whiteList.includes(path) || whiteList.some(route => path.startsWith(route))
}
/**
* 权限检查守卫
*/
const authGuard = async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const authStore = useAuthStore()
// 如果目标路由不需要认证,直接通过
if (!requiresAuth(to.path)) {
next()
return
}
// 检查是否已登录
if (!authStore.isLoggedIn) {
// 未登录,跳转到登录页
ElMessage.warning('请先登录')
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 已登录,检查token是否有效
try {
// 这里可以添加token验证逻辑
// const isValid = await authStore.validateToken()
// if (!isValid) {
// throw new Error('Token无效')
// }
next()
} catch (error) {
console.error('Token验证失败:', error)
ElMessage.error('登录状态已过期,请重新登录')
// 清除认证状态
await authStore.logout()
// 跳转到登录页
next({
path: '/login',
query: { redirect: to.fullPath }
})
}
}
/**
* 页面标题守卫
*/
const titleGuard = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
// 设置页面标题
const title = to.meta.title as string
if (title) {
document.title = `${title} - ${envConfig.appTitle}`
} else {
document.title = envConfig.appTitle
}
next()
}
/**
* 页面加载进度守卫
*/
const progressGuard = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
// 这里可以添加页面加载进度条逻辑
// NProgress.start()
next()
}
/**
* 页面加载完成守卫
*/
const progressDoneGuard = () => {
// 这里可以添加页面加载完成逻辑
// NProgress.done()
}
/**
* 登录重定向守卫
*/
const loginRedirectGuard = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const authStore = useAuthStore()
// 如果已登录且访问登录页,重定向到首页
if (authStore.isLoggedIn && (to.path === '/login' || to.path === '/register')) {
const redirect = to.query.redirect as string || '/'
next(redirect)
return
}
next()
}
// 移除权限检查守卫,该功能不存在
/**
* 安装路由守卫
*/
export const setupRouterGuards = (router: Router) => {
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
try {
// 页面加载进度
// NProgress.start()
const authStore = useAuthStore()
console.log('🔍 路由守卫检查:', {
to: to.path,
from: from.path,
isLoggedIn: authStore.isLoggedIn,
hasToken: !!authStore.accessToken,
hasUserInfo: !!authStore.userInfo
})
// 如果已登录且访问登录页,重定向到首页
if (authStore.isLoggedIn && (to.path === '/login' || to.path === '/register')) {
const redirect = to.query.redirect as string || '/'
console.log('🔍 已登录用户访问登录页,重定向到:', redirect)
next(redirect)
return
}
// 检查是否需要认证
if (requiresAuth(to.path)) {
console.log('🔍 页面需要认证:', to.path)
// 如果当前未登录,先尝试恢复本地存储的认证状态
if (!authStore.isLoggedIn) {
console.log('🔍 路由守卫:尝试恢复本地认证状态')
const restored = authStore.restoreLocalAuth()
console.log('🔍 路由守卫:恢复结果:', restored)
if (restored) {
console.log('🔍 认证状态已恢复:', {
isLoggedIn: authStore.isLoggedIn,
hasToken: !!authStore.accessToken,
hasUserInfo: !!authStore.userInfo
})
}
}
// 再次检查登录状态
if (!authStore.isLoggedIn) {
console.log('🔍 用户未登录,跳转到登录页')
// 未登录,跳转到登录页
ElMessage.warning('请先登录')
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
console.log('🔍 用户已登录,允许访问:', to.path)
}
// 设置页面标题
const title = to.meta.title as string
if (title) {
document.title = `${title} - ${envConfig.appTitle}`
} else {
document.title = envConfig.appTitle
}
next()
} catch (error) {
console.error('路由守卫错误:', error)
next()
}
})
// 全局后置守卫
router.afterEach((to, from) => {
// 页面加载完成
progressDoneGuard()
// 页面访问统计
if (envConfig.debug) {
console.log(`路由跳转: ${from.path} -> ${to.path}`)
}
})
// 全局解析守卫
router.beforeResolve((to, from, next) => {
// 这里可以添加异步数据加载逻辑
next()
})
}
export {
authGuard,
titleGuard,
progressGuard,
progressDoneGuard,
loginRedirectGuard,
requiresAuth,
isInWhiteList
}
+172 -92
View File
@@ -1,5 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { setupRouterGuards } from './guards'
// 扩展路由元信息类型
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
permission?: string
role?: string
icon?: string
hidden?: boolean
keepAlive?: boolean
}
}
const routes: RouteRecordRaw[] = [
{
@@ -7,8 +21,9 @@ const routes: RouteRecordRaw[] = [
name: 'Home',
component: () => import('@/views/Home/index.vue'),
meta: {
title: '开心APP - 你的情绪陪伴使者',
keepAlive: true
title: '首页',
requiresAuth: false,
icon: 'House'
}
},
{
@@ -16,8 +31,20 @@ const routes: RouteRecordRaw[] = [
name: 'Chat',
component: () => import('@/views/Chat/index.vue'),
meta: {
title: '与开开聊天',
requiresAuth: false
title: 'AI对话',
requiresAuth: true,
icon: 'ChatDotRound',
keepAlive: true
}
},
{
path: '/chat-history',
name: 'ChatHistory',
component: () => import('@/views/ChatHistory/index.vue'),
meta: {
title: '聊天历史',
requiresAuth: true,
icon: 'Clock'
}
},
{
@@ -26,34 +53,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/Diary/index.vue'),
meta: {
title: '情绪日记',
requiresAuth: false
requiresAuth: true,
icon: 'EditPen'
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard/index.vue'),
path: '/life-milestones',
name: 'LifeMilestones',
component: () => import('@/views/LifeMilestones/index.vue'),
meta: {
title: '个人展板',
requiresAuth: false
}
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile/index.vue'),
meta: {
title: '个人中心',
requiresAuth: true
}
},
{
path: '/topic-tracker',
name: 'TopicTracker',
component: () => import('@/views/TopicTracker/index.vue'),
meta: {
title: '话题追踪',
requiresAuth: false
title: '人生里程碑',
requiresAuth: true,
icon: 'Trophy'
}
},
{
@@ -62,7 +73,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/LifeTrajectory/index.vue'),
meta: {
title: '人生轨迹',
requiresAuth: false
requiresAuth: true,
icon: 'TrendCharts'
}
},
{
@@ -71,7 +83,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/Messages/index.vue'),
meta: {
title: '消息中心',
requiresAuth: false
requiresAuth: true,
icon: 'Message'
}
},
{
path: '/personal-dashboard',
name: 'PersonalDashboard',
component: () => import('@/views/PersonalDashboard/index.vue'),
meta: {
title: '个人仪表盘',
requiresAuth: true,
icon: 'DataBoard'
}
},
{
@@ -79,26 +102,81 @@ const routes: RouteRecordRaw[] = [
name: 'Settings',
component: () => import('@/views/Settings/index.vue'),
meta: {
title: '用户设置',
requiresAuth: false
title: '设置',
requiresAuth: true,
icon: 'Setting'
}
},
{
path: '/chat-history',
name: 'ChatHistory',
component: () => import('@/views/Chat/History.vue'),
path: '/topic-tracker',
name: 'TopicTracker',
component: () => import('@/views/TopicTracker/index.vue'),
meta: {
title: '聊天历史',
requiresAuth: false
title: '话题追踪',
requiresAuth: true,
icon: 'Search'
}
},
// 兼容原有页面
{
path: '/emotion',
name: 'Emotion',
component: () => import('@/views/Emotion/index.vue'),
meta: {
title: '情绪管理',
requiresAuth: true,
icon: 'Sunny'
}
},
{
path: '/map',
name: 'Map',
component: () => import('@/views/Map/index.vue'),
meta: {
title: '情绪地图',
requiresAuth: true,
icon: 'Location'
}
},
{
path: '/social',
name: 'Social',
component: () => import('@/views/Social/index.vue'),
meta: {
title: '社交分享',
requiresAuth: true,
icon: 'Share'
}
},
{
path: '/analysis',
name: 'Analysis',
component: () => import('@/views/Analysis/index.vue'),
meta: {
title: '情绪分析',
requiresAuth: true,
icon: 'TrendCharts'
}
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile/index.vue'),
meta: {
title: '个人中心',
requiresAuth: true,
icon: 'User'
}
},
// 认证相关页面
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login/index.vue'),
meta: {
title: '用户登录',
requiresAuth: false
title: '登录',
requiresAuth: false,
hidden: true
}
},
{
@@ -106,25 +184,65 @@ const routes: RouteRecordRaw[] = [
name: 'Register',
component: () => import('@/views/Register/index.vue'),
meta: {
title: '用户注册',
requiresAuth: false
title: '注册',
requiresAuth: false,
hidden: true
}
},
// 调试页面(仅开发环境)
{
path: '/debug',
name: 'Debug',
component: () => import('@/views/Debug/index.vue'),
meta: {
title: '环境变量调试',
requiresAuth: false,
hidden: true
}
},
{
path: '/debug/websocket',
name: 'WebSocketTest',
component: () => import('@/views/Debug/WebSocketTest.vue'),
meta: {
title: 'WebSocket测试',
requiresAuth: false,
hidden: true
}
},
// 错误页面
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/NotFound/index.vue'),
meta: {
title: '访问被拒绝',
requiresAuth: false,
hidden: true
}
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/NotFound/index.vue'),
meta: {
title: '页面未找到',
requiresAuth: false,
hidden: true
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: {
title: '页面未找到'
}
redirect: '/404'
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHistory('/emotion-museum/'),
routes,
scrollBehavior(_to, _from, savedPosition) {
scrollBehavior(to, from, savedPosition) {
// 路由切换时的滚动行为
if (savedPosition) {
return savedPosition
} else {
@@ -133,49 +251,11 @@ const router = createRouter({
}
})
// 路由守卫
router.beforeEach(async (to, _from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title as string
}
// 检查是否需要认证
if (to.meta.requiresAuth) {
// 动态导入用户store以避免循环依赖
const { useUserStore } = await import('@/stores/user')
const userStore = useUserStore()
if (!userStore.isLoggedIn) {
// 保存当前路径,登录后跳转回来
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
}
// 如果已登录用户访问登录/注册页面,重定向到首页
if (to.path === '/login' || to.path === '/register') {
const { useUserStore } = await import('@/stores/user')
const userStore = useUserStore()
console.log('路由守卫检查登录状态:', {
path: to.path,
isLoggedIn: userStore.isLoggedIn,
token: !!userStore.token,
userInfo: !!userStore.userInfo
})
if (userStore.isLoggedIn) {
console.log('用户已登录,重定向到首页')
next('/')
return
}
}
next()
})
// 设置路由守卫
setupRouterGuards(router)
export default router
// 导出路由相关工具
export { routes }
export type { RouteRecordRaw }
-184
View File
@@ -1,184 +0,0 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
// 创建axios实例
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加请求时间戳
config.headers['X-Request-Time'] = Date.now().toString()
return config
},
(error) => {
console.error('Request error:', error)
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
// 检查业务状态码
if (data.code !== 200) {
console.error('API Business Error:', {
code: data.code,
message: data.message,
url: response.config.url
})
// 对于认证错误,特殊处理
if (data.code === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
const error = new Error(data.message || '请求失败') as any
error.response = response
return Promise.reject(error)
}
return response
},
(error) => {
// 处理HTTP错误
if (error.response) {
const { status, data } = error.response
console.error('HTTP Error:', {
status,
url: error.config?.url,
message: data?.message || error.message
})
switch (status) {
case 401:
// 未授权,清除token并跳转到登录页
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
console.error('Access forbidden')
break
case 404:
console.error('Resource not found')
break
case 500:
console.error('Server error')
break
default:
console.error('HTTP Error:', status, data?.message || error.message)
}
} else if (error.request) {
console.error('Network error:', error.message)
} else {
console.error('Request setup error:', error.message)
}
return Promise.reject(error)
}
)
// 通用请求方法
export const request = {
get: <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> =>
api.get(url, config).then(res => res.data.data),
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
api.post(url, data, config).then(res => res.data.data),
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
api.put(url, data, config).then(res => res.data.data),
delete: <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> =>
api.delete(url, config).then(res => res.data.data),
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> =>
api.patch(url, data, config).then(res => res.data.data),
}
// 文件上传
export const uploadFile = (file: File, onProgress?: (progress: number) => void): Promise<string> => {
const formData = new FormData()
formData.append('file', file)
return api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(progress)
}
},
}).then(res => res.data.data.url)
}
// 消息相关API
export const messageApi = {
// 获取用户消息分页
getUserMessages: (current: number = 1, size: number = 20) =>
request.get(`/message/user/page`, { params: { current, size } }),
// 搜索用户消息
searchUserMessages: (keyword: string, limit: number = 50) =>
request.post(`/message/user/search`, { keyword, limit }),
// 获取用户最近的聊天记录
getRecentMessages: (limit: number = 10) =>
request.post(`/message/user/recent`, { limit }),
// 获取消息详情
getMessageById: (id: string) =>
request.get(`/message/${id}`)
}
// 情绪记录相关API
export const emotionRecordApi = {
// 获取用户情绪记录分页
getUserEmotionRecords: (current: number = 1, size: number = 10) =>
request.get(`/emotion-records/user`, { params: { current, size } }),
// 获取用户最近情绪记录
getUserRecentEmotionRecords: (limit: number = 5) =>
request.get(`/emotion-records/user/recent`, { params: { limit } }),
// 获取情绪记录详情
getEmotionRecordById: (id: string) =>
request.get(`/emotion-records/${id}`),
// 删除情绪记录
deleteEmotionRecord: (id: string) =>
request.delete(`/emotion-records/${id}`)
}
// 情绪总结相关API
export const emotionSummaryApi = {
// 生成情绪记录总结
generateEmotionSummary: () =>
request.post(`/emotion-summary/generate`),
// 获取情绪记录总结状态
getEmotionSummaryStatus: () =>
request.get(`/emotion-summary/status`)
}
export default api
+197 -90
View File
@@ -1,114 +1,221 @@
import request from '@/utils/request'
/**
* 认证相关API服务
*/
import { http } from '@/utils/request'
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
AuthResponse,
UserInfo,
CaptchaResponse,
ApiResponse,
RefreshTokenRequest,
ChangePasswordRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
UserInfo
SendCodeRequest,
VerifyCodeRequest,
OAuthLoginRequest,
BindOAuthRequest,
UnbindOAuthRequest,
OAuthInfo,
LoginHistory,
OnlineUser
} from '@/types/auth'
export const authService = {
// 获取验证码
async getCaptcha(): Promise<CaptchaResponse> {
return await request.get('/auth/captcha')
},
/**
* 认证API服务类
*/
export class AuthService {
// 用户登录
async login(data: LoginRequest): Promise<LoginResponse> {
return await request.post('/auth/login', data)
},
// 用户注册
async register(data: RegisterRequest): Promise<LoginResponse> {
return await request.post('/auth/register', data)
},
// 刷新token
async refreshToken(data: RefreshTokenRequest): Promise<ApiResponse<LoginResponse>> {
const response = await request.post('/auth/refresh-token', data)
/**
* 用户登录
*/
static async login(data: LoginRequest): Promise<AuthResponse> {
const response = await http.post<AuthResponse>('/auth/login', data)
return response.data
},
}
// 用户登出
async logout(): Promise<ApiResponse<void>> {
const response = await request.post('/auth/logout')
/**
* 用户注册
*/
static async register(data: RegisterRequest): Promise<AuthResponse> {
const response = await http.post<AuthResponse>('/auth/register', data)
return response.data
},
}
// 获取用户信息
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
const response = await request.get('/auth/user-info')
/**
* 获取验证码
*/
static async getCaptcha(): Promise<CaptchaResponse> {
const response = await http.get<CaptchaResponse>('/auth/captcha')
return response.data
},
}
// 修改密码
async changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
const response = await request.post('/auth/change-password', data)
/**
* 用户登出
*/
static async logout(): Promise<void> {
const response = await http.post<void>('/auth/logout')
return response.data
},
}
// 忘记密码
async forgotPassword(data: ForgotPasswordRequest): Promise<ApiResponse<void>> {
return await request.post('/forgot-password', data)
},
/**
* 刷新Token
*/
static async refreshToken(data: RefreshTokenRequest): Promise<AuthResponse> {
const response = await http.post<AuthResponse>('/auth/refresh-token', data)
return response.data
}
// 重置密码
async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse<void>> {
return await request.post('/reset-password', data)
},
/**
* 获取当前用户信息
*/
static async getCurrentUserInfo(): Promise<UserInfo> {
const response = await http.get<UserInfo>('/auth/user/info')
return response.data
}
// 验证token有效性
async validateToken(): Promise<ApiResponse<boolean>> {
return await request.get('/validate-token')
},
/**
* 验证Token是否有效
*/
static async validateToken(): Promise<boolean> {
const response = await http.get<boolean>('/auth/validate-token')
return response.data
}
// 检查账号是否存在
async checkAccount(account: string): Promise<ApiResponse<boolean>> {
return await request.get(`/check-account?account=${account}`)
/**
* 修改密码
*/
static async changePassword(data: ChangePasswordRequest): Promise<void> {
return http.post<void>('/auth/change-password', data)
}
/**
* 重置密码
*/
static async resetPassword(data: ResetPasswordRequest): Promise<void> {
return http.post<void>('/auth/reset-password', data)
}
/**
* 发送验证码
*/
static async sendCode(data: SendCodeRequest): Promise<void> {
return http.post<void>('/auth/send-code', data)
}
/**
* 验证验证码
*/
static async verifyCode(data: VerifyCodeRequest): Promise<boolean> {
return http.post<boolean>('/auth/verify-code', data)
}
/**
* 第三方登录
*/
static async oauthLogin(data: OAuthLoginRequest): Promise<AuthResponse> {
return http.post<AuthResponse>('/auth/oauth/login', data)
}
/**
* 绑定第三方账号
*/
static async bindOAuth(data: BindOAuthRequest): Promise<void> {
return http.post<void>('/auth/oauth/bind', data)
}
/**
* 解绑第三方账号
*/
static async unbindOAuth(data: UnbindOAuthRequest): Promise<void> {
return http.post<void>('/auth/oauth/unbind', data)
}
/**
* 获取第三方账号绑定信息
*/
static async getOAuthInfo(): Promise<OAuthInfo[]> {
return http.get<OAuthInfo[]>('/auth/oauth/info')
}
// 移除权限接口,该接口不存在
/**
* 获取登录历史
*/
static async getLoginHistory(page = 1, size = 10): Promise<{
list: LoginHistory[]
total: number
page: number
size: number
}> {
return http.get<{
list: LoginHistory[]
total: number
page: number
size: number
}>('/auth/login-history', {
params: { page, size }
})
}
/**
* 获取在线用户列表(管理员功能)
*/
static async getOnlineUsers(page = 1, size = 10): Promise<{
list: OnlineUser[]
total: number
page: number
size: number
}> {
return http.get<{
list: OnlineUser[]
total: number
page: number
size: number
}>('/auth/online-users', {
params: { page, size }
})
}
/**
* 强制下线用户(管理员功能)
*/
static async forceLogout(userId: string): Promise<void> {
return http.post<void>(`/auth/force-logout/${userId}`)
}
/**
* 检查账号是否存在
*/
static async checkAccountExists(account: string): Promise<boolean> {
const response = await http.get<boolean>('/auth/check-account', {
params: { account }
})
return response.data
}
/**
* 检查邮箱是否存在
*/
static async checkEmailExists(email: string): Promise<boolean> {
const response = await http.get<boolean>('/auth/check-email', {
params: { email }
})
return response.data
}
/**
* 检查手机号是否存在
*/
static async checkPhoneExists(phone: string): Promise<boolean> {
const response = await http.get<boolean>('/auth/check-phone', {
params: { phone }
})
return response.data
}
}
// 工具函数
export const authUtils = {
// 获取token
getToken(): string | null {
return localStorage.getItem('token')
},
// 设置token
setToken(token: string): void {
localStorage.setItem('token', token)
},
// 移除token
removeToken(): void {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
},
// 获取用户信息
getUserInfo(): UserInfo | null {
const userInfo = localStorage.getItem('userInfo')
return userInfo ? JSON.parse(userInfo) : null
},
// 设置用户信息
setUserInfo(userInfo: UserInfo): void {
localStorage.setItem('userInfo', JSON.stringify(userInfo))
},
// 检查是否已登录
isLoggedIn(): boolean {
return !!this.getToken()
},
// 清除所有认证信息
clearAuth(): void {
this.removeToken()
}
}
// 导出默认实例
export default AuthService
+196 -49
View File
@@ -1,51 +1,198 @@
import { request } from './api'
import type { ChatMessage, ChatSession, PaginatedResponse } from '@/types'
import { http } from '@/utils/request'
import type { ChatMessage, ChatSession } from '@/types'
export const chatApi = {
// 发送AI聊天消息(REST备用,主用WebSocket
sendAiMessage: (conversationId: string, message: string, userId: string): Promise<any> =>
request.post('/ai/chat', { conversationId, message, userId }),
// 创建会话
createSession: (userId: string, title: string): Promise<ChatSession> =>
request.post('/conversation', { userId, title }),
// 获取会话分页
getSessions: (params: { page: number, size: number, userId?: string }): Promise<PaginatedResponse<ChatSession>> =>
request.get('/conversation/page', { params }),
// 获取用户所有会话
getUserSessions: (userId: string): Promise<ChatSession[]> =>
request.get(`/conversation/user/${userId}`),
// 删除会话
deleteSession: (id: string): Promise<void> =>
request.delete(`/conversation/${id}`),
// 更新会话标题
updateSessionTitle: (id: string, title: string): Promise<ChatSession> =>
request.put(`/conversation/${id}`, { title }),
// 获取会话消息分页
getSessionMessages: (conversationId: string, params: { page: number, size: number }): Promise<PaginatedResponse<ChatMessage>> =>
request.get(`/message/conversation/${conversationId}/page`, { params }),
// 获取会话所有消息
getAllSessionMessages: (conversationId: string): Promise<ChatMessage[]> =>
request.get(`/message/conversation/${conversationId}`),
// 创建消息(保存到数据库)
createMessage: (data: {
conversationId: string,
userId: string,
content: string,
contentType?: string,
senderType?: string,
senderId?: string
}): Promise<ChatMessage> =>
request.post('/message', data),
// 聊天统计
getChatStats: (userId?: string, conversationId?: string): Promise<any> =>
request.get('/ai/stats', { params: { userId, conversationId } }),
export interface CreateSessionRequest {
userId: string
title?: string
}
export interface CreateSessionResponse {
id: string
title: string
userId: string
createTime: string
updateTime: string
messageCount: number
}
export interface GetSessionsResponse {
sessions: ChatSession[]
total: number
}
export interface GetMessagesResponse {
messages: ChatMessage[]
total: number
}
// 后端实际返回的会话数据结构
export interface ConversationResponse {
id: string
userId: string
userType: string
title: string
type: string
status: string
startTime: string
endTime?: string
lastActiveTime: string
messageCount: number
createTime: string
updateTime: string
}
/**
* 聊天API服务
*/
export class ChatApiService {
/**
* 创建聊天会话
*/
async createSession(userId: string, title?: string): Promise<ChatSession> {
try {
console.log('📝 创建会话API调用:', { userId, title })
const response = await http.post<ConversationResponse>('/conversation', {
userId,
title: title || `对话${Date.now()}`
})
console.log('📝 创建会话API响应:', response)
// 处理HTTP响应的data字段
const data = (response as any).data || response
// 转换后端数据格式为前端格式 - 完整匹配ChatSession接口
return {
id: data.id,
title: data.title,
userId: data.userId || data.user_id,
userType: data.userType || data.user_type,
type: data.type,
status: data.status,
createTime: data.createTime || data.create_time,
updateTime: data.updateTime || data.update_time,
messageCount: data.messageCount || data.message_count || 0,
startTime: data.startTime || data.start_time,
endTime: data.endTime || data.end_time,
lastActiveTime: data.lastActiveTime || data.last_active_time
}
} catch (error) {
console.error('❌ 创建会话失败:', error)
throw error
}
}
/**
* 获取用户的所有会话
*/
async getUserSessions(userId: string): Promise<ChatSession[]> {
try {
console.log('📂 获取用户会话API调用:', { userId })
const response = await http.get<ConversationResponse[]>(`/conversation/user/${userId}`)
console.log('📂 获取用户会话API响应:', response)
// 处理HTTP响应的data字段
const data = (response as any).data || response
// 后端返回ConversationResponse数组,需要转换为ChatSession格式
if (Array.isArray(data)) {
return data.map((conv: any) => ({
id: conv.id,
title: conv.title,
userId: conv.userId || conv.user_id, // 兼容不同的字段名
createTime: conv.createTime || conv.create_time,
updateTime: conv.updateTime || conv.update_time,
messageCount: conv.messageCount || conv.message_count || 0
}))
}
return []
} catch (error) {
console.error('❌ 获取用户会话失败:', error)
return []
}
}
/**
* 获取会话的所有消息
* 注意:后端没有按会话ID获取消息的接口,这里返回空数组
* 实际的消息加载通过messageApi的接口实现
*/
async getAllSessionMessages(sessionId: string): Promise<ChatMessage[]> {
console.log('⚠️ getAllSessionMessages: 后端没有此接口,返回空数组。会话ID:', sessionId)
console.log('💡 建议使用messageApi.getRecentMessages()或messageApi.getUserMessages()获取消息')
// 返回空数组,避免404错误
// 实际的消息加载由messageApi处理
return []
}
/**
* 删除会话
*/
async deleteSession(sessionId: string): Promise<void> {
try {
console.log('🗑️ 删除会话API调用:', { sessionId })
await http.delete(`/conversation/${sessionId}`)
console.log('✅ 删除会话成功')
} catch (error) {
console.error('❌ 删除会话失败:', error)
throw error
}
}
/**
* 更新会话标题
*/
async updateSessionTitle(sessionId: string, title: string): Promise<void> {
try {
console.log('✏️ 更新会话标题API调用:', { sessionId, title })
await http.put(`/conversation/${sessionId}`, { title })
console.log('✅ 更新会话标题成功')
} catch (error) {
console.error('❌ 更新会话标题失败:', error)
throw error
}
}
/**
* 获取最近的消息
* 注意:这个方法已废弃,请使用messageApi.getRecentMessages()
*/
async getRecentMessages(params: { limit?: number } = {}): Promise<ChatMessage[]> {
try {
console.log('⚠️ getRecentMessages: 此方法已废弃,请使用messageApi.getRecentMessages()')
console.log('💡 建议直接调用messageApi.getRecentMessages(limit)')
console.log('📝 参数:', params)
// 返回空数组,避免调用不存在的接口
return []
} catch (error) {
console.error('❌ getRecentMessages错误:', error)
return []
}
}
/**
* 搜索消息
* 注意:这个方法已废弃,请使用messageApi.searchUserMessages()
*/
async searchMessages(keyword: string, sessionId?: string): Promise<ChatMessage[]> {
try {
console.log('⚠️ searchMessages: 此方法已废弃,请使用messageApi.searchUserMessages()')
console.log('💡 建议直接调用messageApi.searchUserMessages(keyword, limit)')
console.log('📝 参数:', { keyword, sessionId })
// 返回空数组,避免调用不存在的接口
return []
} catch (error) {
console.error('❌ searchMessages错误:', error)
return []
}
}
}
// 创建聊天API服务实例
export const chatApi = new ChatApiService()
export default chatApi
+11
View File
@@ -0,0 +1,11 @@
import request from '@/utils/request';
export function publishDiary(data: any) {
return request.post('/diary-post/publish', data);
}
export function getUserDiaries(userId: string, page = 1, size = 10) {
return request.get(`/diary-post/user/${userId}/page`, {
params: { current: page, size }
});
}
+176
View File
@@ -0,0 +1,176 @@
import { http } from '@/utils/request'
import type { ChatMessage } from '@/types'
// 消息相关类型定义
export interface MessageResponse {
id: string
conversationId: string
content: string
type: string
sender: string
isRead: number
aiReply?: string
emotionAnalysis?: string
createTime: string
updateTime: string
}
export interface PageResult<T> {
records: T[]
total: number
size: number
current: number
pages: number
}
export interface MessagePageRequest {
current: number
size: number
conversationId?: string
type?: string
sender?: string
startTime?: string
endTime?: string
}
export interface MessageSearchRequest {
keyword: string
limit: number
conversationId?: string
type?: string
sender?: string
startTime?: string
endTime?: string
}
export interface MessageRecentRequest {
limit: number
conversationId?: string
type?: string
sender?: string
}
/**
* 消息API服务 - 与web项目保持一致的接口
*/
export const messageApi = {
// 获取用户消息分页
getUserMessages: async (current: number = 1, size: number = 20) => {
console.log('📨 调用getUserMessages API:', { current, size })
const response = await http.get(`/message/user/page`, { params: { current, size } })
console.log('📨 getUserMessages API响应:', response)
return response
},
// 搜索用户消息
searchUserMessages: async (keyword: string, limit: number = 50) => {
console.log('🔍 调用searchUserMessages API:', { keyword, limit })
const response = await http.post(`/message/user/search`, { keyword, limit })
console.log('🔍 searchUserMessages API响应:', response)
return response
},
// 获取用户最近的聊天记录 - 修复:使用POST请求匹配后端接口
getRecentMessages: async (limit: number = 10) => {
console.log('📝 调用getRecentMessages API:', { limit })
const response = await http.post(`/message/user/recent`, { limit })
console.log('📝 getRecentMessages API响应:', response)
return response
},
// 获取消息详情
getMessageById: async (id: string) => {
console.log('📄 调用getMessageById API:', { id })
const response = await http.get(`/message/${id}`)
console.log('📄 getMessageById API响应:', response)
return response
}
}
/**
* 消息服务类 - 提供高级封装
*/
class MessageService {
/**
* 获取用户消息分页
*/
static async getUserMessages(current: number = 1, size: number = 20): Promise<PageResult<MessageResponse>> {
const response = await messageApi.getUserMessages(current, size)
return response.data || response
}
/**
* 搜索用户消息
*/
static async searchUserMessages(request: MessageSearchRequest): Promise<MessageResponse[]> {
const response = await messageApi.searchUserMessages(request.keyword, request.limit)
return response.data || response
}
/**
* 获取用户最近的聊天记录
*/
static async getRecentMessages(request: MessageRecentRequest): Promise<MessageResponse[]> {
console.log('📝 MessageService.getRecentMessages 调用:', request)
const response = await messageApi.getRecentMessages(request.limit)
console.log('📝 MessageService.getRecentMessages 响应:', response)
// 处理响应数据结构
const messageList = response.data || response || []
return Array.isArray(messageList) ? messageList : []
}
/**
* 根据ID获取消息详情
*/
static async getMessageById(id: string): Promise<MessageResponse> {
const response = await messageApi.getMessageById(id)
return response.data || response
}
/**
* 转换消息格式为聊天消息格式 - 完整匹配后端字段
*/
static convertToChatMessage(msg: MessageResponse): ChatMessage {
console.log('🔄 转换消息格式:', msg)
// 处理时间格式 - 后端返回 "2025-07-26 22:09:10" 格式
// 直接保持原始字符串格式,让前端UI层处理
let timestamp = msg.createTime
console.log('🕐 保持原始时间格式:', msg.createTime)
const chatMessage: ChatMessage = {
id: msg.id,
content: msg.content,
type: msg.sender === 'user' ? 'user' : (msg.sender === 'ai' ? 'ai' : 'system'),
timestamp: timestamp,
conversationId: msg.conversationId,
sessionId: msg.conversationId, // 别名,保持兼容性
status: 'sent',
sender: msg.sender as 'user' | 'ai' | 'system',
isRead: msg.isRead,
role: msg.sender === 'user' ? 'user' : 'assistant' // 用于UI显示
}
console.log('✅ 转换后的消息详情:', {
原始sender: msg.sender,
转换后role: chatMessage.role,
转换后type: chatMessage.type,
时间: msg.createTime + ' -> ' + timestamp
})
console.log('✅ 转换后的消息:', chatMessage)
return chatMessage
}
/**
* 批量转换消息格式
*/
static convertToChatMessages(messages: MessageResponse[]): ChatMessage[] {
console.log('🔄 批量转换消息格式,数量:', messages.length)
const chatMessages = messages.map(msg => this.convertToChatMessage(msg))
console.log('✅ 批量转换完成,结果:', chatMessages)
return chatMessages
}
}
export default MessageService
+372
View File
@@ -0,0 +1,372 @@
import { Client, IMessage } from '@stomp/stompjs'
import SockJS from 'sockjs-client'
import { envConfig } from '@/config/env'
// WebSocket消息类型
export interface WebSocketMessage {
messageId?: string
conversationId?: string
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
content: string
senderId: string
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
status?: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
createTime?: string
timestamp?: number
data?: any
}
// 聊天请求类型 - 完全匹配后端ChatRequest
export interface ChatRequest {
content: string
senderId: string
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
messageType: 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM' | 'HEARTBEAT'
conversationId?: string
timestamp?: number
}
// 连接状态
export type ConnectionStatus = 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'ERROR'
// 事件回调类型
export interface WebSocketCallbacks {
onMessage?: (message: WebSocketMessage) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: any) => void
onStatusChange?: (status: ConnectionStatus) => void
}
/**
* STOMP WebSocket服务类
* 使用STOMP协议与后端Spring WebSocket通信
*/
export class StompWebSocketService {
private client: Client | null = null
private callbacks: WebSocketCallbacks = {}
private status: ConnectionStatus = 'DISCONNECTED'
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectInterval = 3000
private userId: string | null = null
private conversationId: string | null = null
constructor() {
// 构建WebSocket URL
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
console.log('STOMP WebSocket URL:', wsUrl)
}
/**
* 连接WebSocket
*/
connect(userId?: string, callbacks?: WebSocketCallbacks): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.callbacks = { ...callbacks }
// 设置用户ID和类型
if (userId) {
this.userId = userId
console.log('🔌 使用登录用户ID:', userId)
} else {
this.userId = `guest_${Date.now()}`
console.log('🔌 使用访客ID:', this.userId)
}
this.setStatus('CONNECTING')
// 创建STOMP客户端
this.client = new Client({
webSocketFactory: () => {
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
return new SockJS(wsUrl)
},
// 连接头信息
connectHeaders: this.getConnectHeaders(),
// 调试信息
debug: (str) => {
console.log('STOMP Debug:', str)
},
// 重连配置
reconnectDelay: this.reconnectInterval,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
})
// 连接成功回调
this.client.onConnect = (frame) => {
console.log('✅ STOMP WebSocket连接成功:', frame)
this.setStatus('CONNECTED')
this.reconnectAttempts = 0
// 订阅消息
this.subscribeToMessages()
// 发送连接消息
this.sendConnectMessage()
this.callbacks.onConnect?.()
resolve()
}
// 连接错误回调
this.client.onStompError = (frame) => {
console.error('❌ STOMP连接错误:', frame)
this.setStatus('ERROR')
this.callbacks.onError?.(frame)
reject(new Error(`STOMP连接错误: ${frame.headers['message']}`))
}
// WebSocket错误回调
this.client.onWebSocketError = (error) => {
console.error('❌ WebSocket错误:', error)
this.setStatus('ERROR')
this.callbacks.onError?.(error)
}
// 连接关闭回调
this.client.onWebSocketClose = (event) => {
console.log('🔌 WebSocket连接关闭:', event)
this.setStatus('DISCONNECTED')
this.callbacks.onDisconnect?.()
// 自动重连
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
// 激活连接
this.client.activate()
} catch (error) {
console.error('❌ STOMP连接异常:', error)
this.setStatus('ERROR')
this.callbacks.onError?.(error)
reject(error)
}
})
}
/**
* 断开连接
*/
disconnect(): void {
if (this.client) {
this.client.deactivate()
this.client = null
}
this.userId = null
this.conversationId = null
this.setStatus('DISCONNECTED')
}
/**
* 发送聊天消息
*/
sendChatMessage(content: string, conversationId?: string): void {
if (!this.client || !this.client.connected) {
const error = new Error('STOMP客户端未连接,无法发送消息')
console.error('STOMP未连接')
this.callbacks.onError?.({ userMessage: '连接已断开,请等待重连后再试', originalError: error })
return
}
if (!content.trim()) {
const error = new Error('消息内容不能为空')
this.callbacks.onError?.({ userMessage: '消息内容不能为空', originalError: error })
return
}
// 判断用户类型
const isGuest = !this.userId || this.userId.startsWith('guest_')
const senderType = isGuest ? 'GUEST' : 'USER'
console.log('📤 发送STOMP聊天消息,用户信息:', {
userId: this.userId,
senderType,
isGuest,
content: content.trim()
})
// 创建聊天请求 - 匹配后端ChatRequest格式
const chatRequest: ChatRequest = {
content: content.trim(),
senderId: this.userId!,
senderType,
messageType: 'TEXT',
conversationId: conversationId || this.conversationId || undefined,
timestamp: Date.now()
}
console.log('📤 准备发送的聊天请求:', chatRequest)
try {
// 发送到后端的/app/chat.send端点
this.client.publish({
destination: '/app/chat.send',
body: JSON.stringify(chatRequest)
})
console.log('✅ STOMP聊天消息发送成功:', chatRequest)
} catch (error) {
console.error('❌ STOMP消息发送失败:', error)
this.callbacks.onError?.({
userMessage: '消息发送失败,请重试',
originalError: error
})
}
}
/**
* 设置会话ID
*/
setConversationId(conversationId: string): void {
this.conversationId = conversationId
console.log('设置会话ID:', conversationId)
}
/**
* 获取连接状态
*/
getStatus(): ConnectionStatus {
return this.status
}
/**
* 是否已连接
*/
isConnected(): boolean {
return this.status === 'CONNECTED' && this.client?.connected === true
}
/**
* 获取连接头信息
*/
private getConnectHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
// 添加用户ID
if (this.userId) {
headers['X-User-Id'] = this.userId
}
// 添加JWT token - 修复:使用正确的localStorage key
const token = localStorage.getItem('access_token')
if (token) {
headers['Authorization'] = `Bearer ${token}`
console.log('🔐 添加Authorization头到STOMP连接,token预览:', token.substring(0, 20) + '...')
} else {
console.warn('🔐 未找到access_tokenWebSocket将以访客身份连接')
}
return headers
}
/**
* 订阅消息
*/
private subscribeToMessages(): void {
if (!this.client?.connected) return
// 订阅用户私有消息
if (this.userId) {
const userQueuePath = `/user/${this.userId}/queue/messages`
console.log('📨 订阅用户私有队列:', userQueuePath)
this.client.subscribe(userQueuePath, (message: IMessage) => {
this.handleMessage(message)
})
}
// 订阅广播消息
this.client.subscribe('/topic/broadcast', (message: IMessage) => {
this.handleMessage(message)
})
// 如果有会话ID,订阅会话特定消息
if (this.conversationId) {
this.client.subscribe(`/topic/conversation/${this.conversationId}`, (message: IMessage) => {
this.handleMessage(message)
})
}
}
/**
* 处理收到的消息
*/
private handleMessage(message: IMessage): void {
try {
const wsMessage: WebSocketMessage = JSON.parse(message.body)
console.log('📨 收到STOMP消息:', wsMessage)
this.callbacks.onMessage?.(wsMessage)
} catch (error) {
console.error('❌ 解析STOMP消息失败:', error, message.body)
}
}
/**
* 发送连接消息
*/
private sendConnectMessage(): void {
if (!this.client?.connected) return
const connectRequest = {
userId: this.userId,
clientType: 'web',
clientVersion: '1.0.0',
timestamp: Date.now()
}
try {
this.client.publish({
destination: '/app/chat.connect',
body: JSON.stringify(connectRequest)
})
console.log('✅ STOMP连接消息发送成功:', connectRequest)
} catch (error) {
console.error('❌ STOMP连接消息发送失败:', error)
}
}
/**
* 设置连接状态
*/
private setStatus(status: ConnectionStatus): void {
this.status = status
this.callbacks.onStatusChange?.(status)
}
/**
* 安排重连
*/
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('达到最大重连次数,停止重连')
return
}
this.reconnectAttempts++
console.log(`${this.reconnectInterval}ms后尝试第${this.reconnectAttempts}次重连`)
setTimeout(() => {
if (this.status !== 'CONNECTED') {
this.connect(this.userId!, this.callbacks).catch(() => {
// 重连失败会自动安排下次重连
})
}
}, this.reconnectInterval)
// 递增重连间隔
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
}
}
// 创建STOMP WebSocket服务实例
export const stompWebSocketService = new StompWebSocketService()
export default stompWebSocketService
+130
View File
@@ -0,0 +1,130 @@
import { http } from '@/utils/request'
// 用户相关类型定义
export interface UserProfile {
id: string
account: string
username: string
nickname?: string // 可选,如果为空则使用username
email?: string // 可选
phone?: string // 可选
avatar?: string // 可选,头像URL
birthDate?: string // 可选,生日
location?: string // 可选,所在地
bio?: string // 可选,个人简介
memberLevel?: string // 可选,会员等级
totalDays?: number // 可选,使用天数
selfAwareness?: number // 可选,自我感知
emotionalResilience?: number // 可选,情绪韧性
actionPower?: number // 可选,行动力
empathy?: number // 可选,共情力
lifeEnthusiasm?: number // 可选,生活热度
status: number
isVerified?: number // 可选,是否已验证
createTime: string
updateTime: string
lastActiveTime?: string // 可选,最后活跃时间
}
export interface UserProfileUpdateRequest {
nickname?: string
email?: string
phone?: string
avatar?: string
birthDate?: string
location?: string
bio?: string
}
export interface GrowthStats {
selfAwareness: number
emotionalResilience: number
actionPower: number
empathy: number
lifeEnthusiasm: number
}
/**
* 用户服务
*/
class UserService {
/**
* 获取当前用户个人资料
*/
static async getCurrentUserProfile(): Promise<UserProfile> {
const response = await http.get<UserProfile>('/user/profile')
return response.data
}
/**
* 更新当前用户个人资料
*/
static async updateCurrentUserProfile(data: UserProfileUpdateRequest): Promise<UserProfile> {
const response = await http.put<UserProfile>('/user/profile', data)
return response.data
}
/**
* 上传头像
*/
static async uploadAvatar(file: File): Promise<string> {
const formData = new FormData()
formData.append('file', file)
const response = await http.post<{ url: string }>('/user/avatar/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data.url
}
/**
* 获取用户成长数据
*/
static async getUserGrowthStats(): Promise<GrowthStats> {
const response = await http.get<GrowthStats>('/user/growth-stats')
return response.data
}
/**
* 更新用户密码
*/
static async updatePassword(data: {
oldPassword: string
newPassword: string
confirmPassword: string
}): Promise<void> {
await http.put('/user/password', data)
}
/**
* 验证邮箱
*/
static async verifyEmail(code: string): Promise<void> {
await http.post('/user/email/verify', { code })
}
/**
* 发送邮箱验证码
*/
static async sendEmailVerificationCode(): Promise<void> {
await http.post('/user/email/send-code')
}
/**
* 验证手机号
*/
static async verifyPhone(code: string): Promise<void> {
await http.post('/user/phone/verify', { code })
}
/**
* 发送手机验证码
*/
static async sendPhoneVerificationCode(): Promise<void> {
await http.post('/user/phone/send-code')
}
}
export default UserService
-423
View File
@@ -1,423 +0,0 @@
import SockJS from 'sockjs-client'
import * as Stomp from 'stompjs'
// import type { ChatMessage } from '@/types' // 暂时注释,未使用
// WebSocket消息类型 - 与后端保持一致
export interface WebSocketMessage {
messageId: string
conversationId?: string
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
content: string
senderId: string
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
status: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
createTime: string
data?: any
}
// 聊天请求类型 - 与后端ChatRequest保持一致
export interface ChatRequest {
content: string
senderId: string
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
messageType: 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM' | 'HEARTBEAT'
conversationId?: string
timestamp?: number
}
// 连接请求类型
export interface ConnectRequest {
userId?: string
username?: string
clientType?: string
clientVersion?: string
timestamp?: number
}
// WebSocket连接状态
export type ConnectionStatus = 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'ERROR'
// 事件回调类型
export interface WebSocketCallbacks {
onMessage?: (message: WebSocketMessage) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: any) => void
onStatusChange?: (status: ConnectionStatus) => void
}
export class WebSocketService {
private client: Stomp.Client | null = null
private callbacks: WebSocketCallbacks = {}
private status: ConnectionStatus = 'DISCONNECTED'
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectInterval = 3000
private heartbeatTimer: number | null = null
private userId: string | null = null
private conversationId: string | null = null
constructor(private wsUrl: string) {}
/**
* 连接WebSocket
*/
connect(userId?: string, callbacks?: WebSocketCallbacks): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.callbacks = { ...callbacks }
this.userId = userId || `guest_${Date.now()}`
this.setStatus('CONNECTING')
// 创建SockJS连接
const socket = new SockJS(this.wsUrl, null, {
transports: ['websocket', 'xhr-streaming', 'xhr-polling']
})
this.client = Stomp.over(socket)
// 禁用调试日志
this.client.debug = () => {}
// 设置心跳
this.client.heartbeat.outgoing = 20000
this.client.heartbeat.incoming = 20000
// 连接配置 - 添加token支持
const connectHeaders: any = {
'X-User-Id': this.userId
}
// 如果有token,添加到连接头中
const token = localStorage.getItem('token')
if (token) {
connectHeaders['Authorization'] = `Bearer ${token}`
}
console.log('WebSocket连接配置:', {
userId: this.userId,
hasToken: !!token,
headers: connectHeaders
})
this.client.connect(
connectHeaders,
(frame) => {
console.log('WebSocket连接成功:', frame)
this.setStatus('CONNECTED')
this.reconnectAttempts = 0
// 订阅用户消息
this.subscribeToMessages()
// 发送连接消息
this.sendConnectMessage()
// 启动心跳
this.startHeartbeat()
this.callbacks.onConnect?.()
resolve()
},
(error) => {
console.error('WebSocket连接失败:', error)
this.setStatus('ERROR')
// 详细的错误处理
let errorMessage = '连接失败'
if (error && typeof error === 'object') {
const errorObj = error as any
if (errorObj.type === 'close') {
switch (errorObj.code) {
case 1006:
errorMessage = '连接异常断开,正在重连...'
break
case 1000:
errorMessage = '连接正常关闭'
break
case 1001:
errorMessage = '服务器正在重启,请稍后重试'
break
case 1002:
errorMessage = '协议错误'
break
case 1003:
errorMessage = '数据格式错误'
break
default:
errorMessage = `连接关闭 (代码: ${errorObj.code})`
}
} else if (errorObj.message) {
errorMessage = errorObj.message
}
} else if (typeof error === 'string') {
errorMessage = error
}
this.callbacks.onError?.({ error, userMessage: errorMessage })
// 尝试重连
this.scheduleReconnect()
reject(error)
}
)
} catch (error) {
console.error('WebSocket初始化失败:', error)
this.setStatus('ERROR')
reject(error)
}
})
}
/**
* 断开连接
*/
disconnect(): void {
if (this.client?.connected) {
this.sendDisconnectMessage()
this.client.disconnect(() => {
console.log('WebSocket已断开连接')
})
}
this.stopHeartbeat()
this.setStatus('DISCONNECTED')
this.callbacks.onDisconnect?.()
}
/**
* 发送聊天消息
*/
sendChatMessage(content: string, conversationId?: string): void {
if (!this.client?.connected) {
const error = new Error('WebSocket连接已断开,无法发送消息')
console.error('WebSocket未连接')
this.callbacks.onError?.({ userMessage: '连接已断开,请等待重连后再试', originalError: error })
return
}
if (!content.trim()) {
const error = new Error('消息内容不能为空')
this.callbacks.onError?.({ userMessage: '消息内容不能为空', originalError: error })
return
}
// 使用新的后端接口格式
const chatRequest: ChatRequest = {
content: content.trim(),
senderId: this.userId!,
senderType: this.userId?.startsWith('guest_') ? 'GUEST' : 'USER',
messageType: 'TEXT',
conversationId: conversationId || this.conversationId || undefined,
timestamp: Date.now()
}
try {
this.client.send('/app/chat.send', {}, JSON.stringify(chatRequest))
console.log('发送聊天消息:', {
...chatRequest,
currentUserId: this.userId,
expectedSubscriptionPath: '/user/queue/messages'
})
} catch (error) {
console.error('发送消息失败:', error)
this.callbacks.onError?.({
userMessage: '消息发送失败,请重试',
originalError: error
})
}
}
/**
* 设置会话ID
*/
setConversationId(conversationId: string): void {
this.conversationId = conversationId
console.log('WebSocket会话ID已更新:', conversationId)
}
/**
* 获取当前会话ID
*/
getConversationId(): string | null {
return this.conversationId
}
/**
* 获取连接状态
*/
getStatus(): ConnectionStatus {
return this.status
}
/**
* 检查是否已连接
*/
isConnected(): boolean {
return this.status === 'CONNECTED' && this.client?.connected === true
}
/**
* 订阅消息
*/
private subscribeToMessages(): void {
if (!this.client?.connected) return
// 订阅用户私有消息 - 包含用户ID的完整路径
if (this.userId) {
const userQueuePath = `/user/${this.userId}/queue/messages`
console.log('订阅用户私有队列:', userQueuePath)
this.client.subscribe(userQueuePath, (message) => {
try {
const wsMessage: WebSocketMessage = JSON.parse(message.body)
console.log('收到用户私有WebSocket消息:', wsMessage)
this.callbacks.onMessage?.(wsMessage)
} catch (error) {
console.error('解析用户私有WebSocket消息失败:', error)
}
})
}
// 同时订阅基于sessionId的队列(从日志看后端也在使用这个)
if (this.client.ws && this.client.ws.url) {
// 从WebSocket URL中提取sessionId
const urlParts = this.client.ws.url.split('/')
const sessionId = urlParts[urlParts.length - 2] // 倒数第二个部分是sessionId
if (sessionId) {
console.log('订阅基于sessionId的队列:', `/queue/messages-user${sessionId}`)
this.client.subscribe(`/queue/messages-user${sessionId}`, (message) => {
try {
const wsMessage: WebSocketMessage = JSON.parse(message.body)
console.log('收到基于sessionId的WebSocket消息:', wsMessage)
this.callbacks.onMessage?.(wsMessage)
} catch (error) {
console.error('解析基于sessionId的WebSocket消息失败:', error)
}
})
}
}
// 订阅广播消息
this.client.subscribe('/topic/broadcast', (message) => {
try {
const wsMessage: WebSocketMessage = JSON.parse(message.body)
console.log('收到广播消息:', wsMessage)
this.callbacks.onMessage?.(wsMessage)
} catch (error) {
console.error('解析广播消息失败:', error)
}
})
// 如果有会话ID,也订阅会话特定的消息
if (this.conversationId) {
this.client.subscribe(`/topic/conversation/${this.conversationId}`, (message) => {
try {
const wsMessage: WebSocketMessage = JSON.parse(message.body)
console.log('收到会话WebSocket消息:', wsMessage)
this.callbacks.onMessage?.(wsMessage)
} catch (error) {
console.error('解析会话WebSocket消息失败:', error)
}
})
}
}
/**
* 发送连接消息
*/
private sendConnectMessage(): void {
if (!this.client?.connected) return
const connectRequest: ConnectRequest = {
userId: this.userId!,
username: this.userId!,
clientType: 'web',
clientVersion: '1.0.0',
timestamp: Date.now()
}
try {
this.client.send('/app/chat.connect', {}, JSON.stringify(connectRequest))
console.log('发送连接消息:', connectRequest)
} catch (error) {
console.error('发送连接消息失败:', error)
}
}
/**
* 发送断开连接消息
*/
private sendDisconnectMessage(): void {
if (!this.client?.connected) return
try {
this.client.send('/app/chat.disconnect', {}, JSON.stringify({}))
} catch (error) {
console.error('发送断开连接消息失败:', error)
}
}
/**
* 启动心跳
*/
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (this.client?.connected) {
try {
this.client.send('/app/chat.heartbeat', {}, JSON.stringify({}))
} catch (error) {
console.error('心跳发送失败:', error)
}
}
}, 30000) // 30秒心跳间隔
}
/**
* 停止心跳
*/
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 设置连接状态
*/
private setStatus(status: ConnectionStatus): void {
this.status = status
this.callbacks.onStatusChange?.(status)
}
/**
* 安排重连
*/
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('达到最大重连次数,停止重连')
return
}
this.reconnectAttempts++
console.log(`${this.reconnectInterval}ms后尝试第${this.reconnectAttempts}次重连`)
setTimeout(() => {
if (this.status !== 'CONNECTED') {
this.connect(this.userId!, this.callbacks).catch(() => {
// 重连失败会自动安排下次重连
})
}
}, this.reconnectInterval)
// 递增重连间隔
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
}
}
// 创建WebSocket服务实例
const wsUrl = import.meta.env.VITE_WS_URL || 'http://localhost:19089/ws/chat'
export const webSocketService = new WebSocketService(wsUrl)
export default webSocketService
-65
View File
@@ -1,65 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ThemeConfig } from '@/types'
export const useAppStore = defineStore('app', () => {
// 应用状态
const loading = ref(false)
const mobileMenuVisible = ref(false)
const theme = ref<ThemeConfig>({
primaryColor: '#4A90E2',
secondaryColor: '#F5A623',
backgroundColor: '#F7F8FA',
textColor: '#333333',
borderRadius: '8px'
})
// 设备信息
const isMobile = ref(false)
const screenWidth = ref(window.innerWidth)
// 方法
const setLoading = (value: boolean) => {
loading.value = value
}
const toggleMobileMenu = () => {
mobileMenuVisible.value = !mobileMenuVisible.value
}
const closeMobileMenu = () => {
mobileMenuVisible.value = false
}
const updateScreenWidth = () => {
screenWidth.value = window.innerWidth
isMobile.value = window.innerWidth < 768
}
const setTheme = (newTheme: Partial<ThemeConfig>) => {
theme.value = { ...theme.value, ...newTheme }
}
// 初始化
const init = () => {
updateScreenWidth()
window.addEventListener('resize', updateScreenWidth)
}
return {
// 状态
loading,
mobileMenuVisible,
theme,
isMobile,
screenWidth,
// 方法
setLoading,
toggleMobileMenu,
closeMobileMenu,
updateScreenWidth,
setTheme,
init
}
})
+284
View File
@@ -0,0 +1,284 @@
/**
* 认证状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import AuthService from '@/services/auth'
import { handleApiError } from '@/utils/errorHandler'
import type {
LoginRequest,
RegisterRequest,
AuthResponse,
UserInfo
} from '@/types/auth'
export const useAuthStore = defineStore('auth', () => {
// 状态
const accessToken = ref<string>('')
const refreshToken = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
// 移除权限状态,该功能不存在
const isLoading = ref(false)
// 计算属性
const isLoggedIn = computed(() => !!accessToken.value && !!userInfo.value)
const userId = computed(() => userInfo.value?.id || '')
const username = computed(() => userInfo.value?.username || '')
const nickname = computed(() => userInfo.value?.nickname || '')
const avatar = computed(() => userInfo.value?.avatar || '')
const email = computed(() => userInfo.value?.email || '')
const phone = computed(() => userInfo.value?.phone || '')
// 移除权限检查方法,该功能不存在
/**
* 初始化认证状态
*/
const initAuth = async () => {
try {
console.log('🔄 初始化认证状态...')
// 从本地存储恢复token
const storedAccessToken = localStorage.getItem('access_token')
const storedRefreshToken = localStorage.getItem('refresh_token')
const storedUserInfo = localStorage.getItem('user_info')
console.log('🔄 本地存储状态:', {
hasToken: !!storedAccessToken,
hasRefreshToken: !!storedRefreshToken,
hasUserInfo: !!storedUserInfo
})
if (storedAccessToken && storedUserInfo) {
// 恢复认证状态
accessToken.value = storedAccessToken
refreshToken.value = storedRefreshToken || ''
userInfo.value = JSON.parse(storedUserInfo)
console.log('🔄 认证状态已恢复')
// 简单验证:尝试获取用户信息来验证token是否有效
try {
await getCurrentUserInfo()
console.log('🔄 Token验证成功')
} catch (error) {
console.warn('🔄 Token可能已过期,但不强制登出:', error)
// 不强制登出,让用户在下次API调用时处理
}
} else {
console.log('🔄 无有效的本地认证信息')
}
} catch (error) {
console.error('🔄 初始化认证状态失败:', error)
// 不自动登出,避免清除用户刚登录的状态
}
}
/**
* 用户登录
*/
const login = async (loginData: LoginRequest): Promise<boolean> => {
try {
isLoading.value = true
console.log('🔐 开始登录流程...')
const response = await AuthService.login(loginData)
console.log('🔐 登录响应数据:', response)
// 保存认证信息
setAuthData(response)
console.log('🔐 认证信息已保存')
// 验证token是否正确保存
const savedToken = localStorage.getItem('access_token')
console.log('🔐 保存的token:', savedToken ? '已保存' : '未保存')
// 获取最新的用户信息
await getCurrentUserInfo()
ElMessage.success('登录成功')
return true
} catch (error: any) {
console.error('🔐 登录失败:', error)
handleApiError(error, '用户登录')
return false
} finally {
isLoading.value = false
}
}
/**
* 用户注册
*/
const register = async (registerData: RegisterRequest): Promise<boolean> => {
try {
isLoading.value = true
const response = await AuthService.register(registerData)
// 保存认证信息
setAuthData(response)
// 移除权限获取,该接口不存在
ElMessage.success('注册成功')
return true
} catch (error: any) {
handleApiError(error, '用户注册')
return false
} finally {
isLoading.value = false
}
}
/**
* 用户登出
*/
const logout = async (): Promise<void> => {
try {
// 调用登出接口
if (accessToken.value) {
await AuthService.logout()
}
} catch (error) {
console.error('登出接口调用失败:', error)
} finally {
// 清除本地状态
clearAuthData()
ElMessage.success('已退出登录')
}
}
/**
* 刷新Token
*/
const refreshAccessToken = async (): Promise<boolean> => {
try {
if (!refreshToken.value) {
throw new Error('没有刷新令牌')
}
const response = await AuthService.refreshToken({
refreshToken: refreshToken.value
})
// 更新认证信息
setAuthData(response)
return true
} catch (error) {
console.error('刷新Token失败:', error)
// 刷新失败,清除认证状态
await logout()
return false
}
}
/**
* 获取当前用户信息
*/
const getCurrentUserInfo = async (): Promise<void> => {
try {
const response = await AuthService.getCurrentUserInfo()
// 后端直接返回用户信息,不是嵌套在userInfo字段中
userInfo.value = response
// 更新本地存储
localStorage.setItem('user_info', JSON.stringify(response))
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
/**
* 静默恢复本地认证状态(不进行API调用)
*/
const restoreLocalAuth = () => {
try {
const storedAccessToken = localStorage.getItem('access_token')
const storedRefreshToken = localStorage.getItem('refresh_token')
const storedUserInfo = localStorage.getItem('user_info')
if (storedAccessToken && storedUserInfo) {
accessToken.value = storedAccessToken
refreshToken.value = storedRefreshToken || ''
userInfo.value = JSON.parse(storedUserInfo)
console.log('🔄 本地认证状态已恢复')
return true
}
return false
} catch (error) {
console.error('🔄 恢复本地认证状态失败:', error)
return false
}
}
/**
* 设置认证数据
*/
const setAuthData = (authData: AuthResponse): void => {
accessToken.value = authData.accessToken
refreshToken.value = authData.refreshToken
userInfo.value = authData.userInfo
// 保存到本地存储
localStorage.setItem('access_token', authData.accessToken)
localStorage.setItem('refresh_token', authData.refreshToken)
localStorage.setItem('user_info', JSON.stringify(authData.userInfo))
}
/**
* 清除认证数据
*/
const clearAuthData = (): void => {
accessToken.value = ''
refreshToken.value = ''
userInfo.value = null
// 清除本地存储
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
}
/**
* 更新用户信息
*/
const updateUserInfo = (newUserInfo: Partial<UserInfo>): void => {
if (userInfo.value) {
userInfo.value = { ...userInfo.value, ...newUserInfo }
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
}
}
return {
// 状态
accessToken,
refreshToken,
userInfo,
isLoading,
// 计算属性
isLoggedIn,
userId,
username,
nickname,
avatar,
email,
phone,
// 方法
initAuth,
restoreLocalAuth,
login,
register,
logout,
refreshAccessToken,
getCurrentUserInfo,
setAuthData,
clearAuthData,
updateUserInfo
}
})
+316 -37
View File
@@ -1,12 +1,34 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import type { ChatMessage, ChatSession } from '@/types'
import webSocketService, { type WebSocketMessage, type ConnectionStatus } from '@/services/websocket'
import { useUserStore } from './user'
import { stompWebSocketService, type WebSocketMessage, type ConnectionStatus } from '@/services/stomp-websocket'
import { useAuthStore } from './auth'
import { chatApi } from '@/services/chat'
import MessageService, { messageApi } from '@/services/message'
// 聊天消息类型
export interface ChatMessage {
id: string
content: string
type: 'user' | 'ai'
timestamp: string
conversationId?: string
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
error?: string
}
// 聊天会话类型
export interface ChatSession {
id: string
title: string
userId?: string
createTime: string
updateTime: string
messageCount: number
}
export const useChatStore = defineStore('chat', () => {
const userStore = useUserStore()
const authStore = useAuthStore()
// 聊天状态
const currentSession = ref<ChatSession | null>(null)
@@ -17,7 +39,16 @@ export const useChatStore = defineStore('chat', () => {
const connectionStatus = ref<ConnectionStatus>('DISCONNECTED')
const wsConnected = ref(false)
// 方法
// 计算属性
const currentMessages = computed(() => {
if (!currentSession.value) return []
return messages.value.filter(msg =>
msg.conversationId === currentSession.value?.id ||
msg.sessionId === currentSession.value?.id
)
})
// 添加消息
const addMessage = (message: Omit<ChatMessage, 'id' | 'timestamp'>) => {
const newMessage: ChatMessage = {
...message,
@@ -60,8 +91,8 @@ export const useChatStore = defineStore('chat', () => {
})
try {
// 仅通过WebSocket推送,后端会统一处理消息保存
webSocketService.sendChatMessage(content, currentSession.value?.id)
// 仅通过STOMP WebSocket推送,后端会统一处理消息保存
stompWebSocketService.sendChatMessage(content, currentSession.value?.id)
// 更新消息状态为已发送
updateMessageStatus(userMessage.id, 'sent')
@@ -87,8 +118,13 @@ export const useChatStore = defineStore('chat', () => {
// 创建会话:同步后端
const createSession = async (title?: string) => {
let newSession: ChatSession
if (userStore.user?.id) {
newSession = await chatApi.createSession(userStore.user.id, title || `对话${sessions.value.length + 1}`)
const currentUserId = authStore.userInfo?.id || authStore.userId
console.log('📝 创建会话,当前用户ID:', currentUserId)
if (currentUserId) {
newSession = await chatApi.createSession(currentUserId, title || `对话${sessions.value.length + 1}`)
console.log('✅ 已为登录用户创建会话:', newSession)
} else {
newSession = {
id: Date.now().toString(),
@@ -97,6 +133,7 @@ export const useChatStore = defineStore('chat', () => {
updateTime: new Date().toISOString(),
messageCount: 0
}
console.log('⚠️ 为访客创建本地会话:', newSession)
}
sessions.value.unshift(newSession)
currentSession.value = newSession
@@ -104,7 +141,7 @@ export const useChatStore = defineStore('chat', () => {
// 如果WebSocket已连接,设置新的会话ID
if (wsConnected.value) {
webSocketService.setConversationId(newSession.id)
stompWebSocketService.setConversationId(newSession.id)
}
return newSession
@@ -119,18 +156,38 @@ export const useChatStore = defineStore('chat', () => {
// 如果WebSocket已连接,更新会话ID
if (wsConnected.value) {
webSocketService.setConversationId(sessionId)
stompWebSocketService.setConversationId(sessionId)
}
}
}
// 加载会话消息:从后端获取
// 加载会话消息:使用现有的消息API
const loadSessionMessages = async (sessionId: string) => {
console.log('📨 开始加载会话消息:', sessionId)
console.log('💡 注意:后端没有按会话ID获取消息的接口,使用最近消息代替')
try {
const msgs = await chatApi.getAllSessionMessages(sessionId)
messages.value = msgs
// 由于后端没有按会话ID获取消息的接口,我们使用最近消息
// 这是一个临时方案,理想情况下应该在后端添加相应接口
console.log('📨 使用最近消息API代替会话消息...')
const response = await messageApi.getRecentMessages(50)
console.log('📨 最近消息API响应:', response)
// 处理API响应数据结构
const messageList = response.data || response || []
console.log('📨 提取的消息列表:', messageList)
const chatMessages = MessageService.convertToChatMessages(messageList)
console.log('📨 转换后的聊天消息:', chatMessages)
// 如果需要过滤特定会话的消息,可以在这里添加过滤逻辑
// const sessionMessages = chatMessages.filter(msg => msg.sessionId === sessionId)
messages.value = chatMessages
console.log('📨 会话消息加载完成,消息数量:', messages.value.length)
} catch (error) {
console.error('Failed to load session messages:', error)
console.error('❌ 加载会话消息失败:', error)
messages.value = []
}
}
@@ -156,14 +213,171 @@ export const useChatStore = defineStore('chat', () => {
}
}
// 清空消息
const clearMessages = () => {
messages.value = []
}
const searchMessages = (keyword: string) => {
return messages.value.filter(message =>
message.content.toLowerCase().includes(keyword.toLowerCase())
)
// 搜索消息:支持本地搜索和远程搜索
const searchMessages = async (keyword: string) => {
console.log('🔍 开始搜索消息:', { keyword })
if (!keyword.trim()) {
console.log('🔍 搜索关键词为空,返回空结果')
return []
}
try {
// 先尝试远程搜索
console.log('🔍 尝试远程搜索...')
const response = await messageApi.searchUserMessages(keyword, 50)
console.log('🔍 远程搜索API响应:', response)
// 处理API响应数据结构
const searchResults = response.data || response || []
console.log('🔍 提取的搜索结果:', searchResults)
const chatMessages = MessageService.convertToChatMessages(searchResults)
console.log('🔍 转换后的搜索结果:', chatMessages)
return chatMessages
} catch (error) {
console.error('❌ 远程搜索失败,使用本地搜索:', error)
// 如果远程搜索失败,使用本地搜索
const localResults = messages.value.filter(message =>
message.content.toLowerCase().includes(keyword.toLowerCase())
)
console.log('🔍 本地搜索结果:', localResults)
return localResults
}
}
// 加载用户历史消息
const loadUserMessages = async (page: number = 1, size: number = 20) => {
console.log('📨 开始加载用户历史消息:', { page, size })
try {
const response = await messageApi.getUserMessages(page, size)
console.log('📨 API响应原始数据:', response)
// 处理API响应数据结构
const result = response.data || response
const messageList = result.records || result.list || []
console.log('📨 提取的消息列表:', messageList)
const chatMessages = MessageService.convertToChatMessages(messageList)
console.log('📨 转换后的聊天消息:', chatMessages)
if (page === 1) {
// 第一页,替换现有消息
messages.value = chatMessages
console.log('📨 第一页数据已加载,消息总数:', messages.value.length)
} else {
// 后续页,追加到现有消息
messages.value = [...messages.value, ...chatMessages]
console.log('📨 追加数据已加载,消息总数:', messages.value.length)
}
const returnData = {
list: messageList,
total: result.total || 0,
page: result.current || page,
size: result.size || size,
pages: result.pages || 0
}
console.log('📨 返回的分页数据:', returnData)
return returnData
} catch (error) {
console.error('❌ 加载用户历史消息失败:', error)
return { list: [], total: 0, page, size, pages: 0 }
}
}
// 加载最近消息
const loadRecentMessages = async (limit: number = 10) => {
console.log('📝 开始加载最近消息:', { limit })
try {
// 直接使用messageApi,避免多层封装
const response = await messageApi.getRecentMessages(limit)
console.log('📝 最近消息API响应:', response)
// 处理响应数据 - 根据您的修改,messageApi现在返回 response.data || response
let messageList = []
if (Array.isArray(response)) {
messageList = response
console.log('📝 直接使用响应数组,消息数量:', messageList.length)
} else if (response && response.data && Array.isArray(response.data)) {
messageList = response.data
console.log('📝 使用response.data,消息数量:', messageList.length)
} else {
console.warn('📝 无法识别的响应格式:', response)
messageList = []
}
console.log('📝 提取的最近消息列表:', messageList)
console.log('📝 第一条消息示例:', messageList[0])
if (messageList.length === 0) {
console.log('📝 没有找到最近消息')
messages.value = []
return []
}
const chatMessages = MessageService.convertToChatMessages(messageList)
console.log('📝 转换后的最近消息:', chatMessages)
// 详细检查每条消息的转换结果
chatMessages.forEach((msg, index) => {
console.log(`📝 消息${index + 1}:`, {
id: msg.id,
content: msg.content.substring(0, 20) + '...',
sender: msg.sender,
type: msg.type,
role: msg.role,
timestamp: msg.timestamp
})
})
// 按时间排序(最新的在后面)
chatMessages.sort((a, b) => {
// 处理时间格式 "2025-07-26 22:09:10" -> ISO格式
const parseTime = (timestamp: string | Date) => {
if (timestamp instanceof Date) {
return timestamp.getTime()
}
if (typeof timestamp === 'string') {
// 如果是 "2025-07-26 22:09:10" 格式,转换为ISO格式
if (timestamp.includes(' ') && !timestamp.includes('T')) {
const isoString = timestamp.replace(' ', 'T')
return new Date(isoString).getTime()
}
return new Date(timestamp).getTime()
}
return new Date().getTime()
}
const timeA = parseTime(a.timestamp)
const timeB = parseTime(b.timestamp)
console.log('📝 排序比较:', {
a: { id: a.id.substring(0, 8), timestamp: a.timestamp, parsed: new Date(timeA).toLocaleString() },
b: { id: b.id.substring(0, 8), timestamp: b.timestamp, parsed: new Date(timeB).toLocaleString() },
result: timeA - timeB
})
return timeA - timeB
})
messages.value = chatMessages
console.log('📝 最近消息已加载并排序,消息总数:', messages.value.length)
return chatMessages
} catch (error) {
console.error('❌ 加载最近消息失败:', error)
messages.value = []
return []
}
}
// 添加AI回复消息(直接显示完整内容)
@@ -184,7 +398,7 @@ export const useChatStore = defineStore('chat', () => {
}
// WebSocket消息处理
const handleWebSocketMessage = (wsMessage: WebSocketMessage) => {
let handleWebSocketMessage = (wsMessage: WebSocketMessage) => {
console.log('收到WebSocket消息:', wsMessage.type, wsMessage.senderType)
switch (wsMessage.type) {
@@ -232,10 +446,16 @@ export const useChatStore = defineStore('chat', () => {
// WebSocket连接管理
const connectWebSocket = async () => {
try {
// 优先使用userInfo中的用户ID,如果没有则使用user中的ID
const userId = userStore.userInfo?.id || userStore.user?.id || undefined
// 获取当前登录用户的ID
const userId = authStore.userInfo?.id || authStore.userId || undefined
console.log('🔌 准备连接WebSocket,当前用户:', {
userId,
userInfo: authStore.userInfo,
isLoggedIn: authStore.isLoggedIn,
accessToken: authStore.accessToken ? '已有token' : '无token'
})
await webSocketService.connect(userId, {
await stompWebSocketService.connect(userId, {
onMessage: handleWebSocketMessage,
onConnect: () => {
console.log('WebSocket连接成功')
@@ -244,7 +464,7 @@ export const useChatStore = defineStore('chat', () => {
// 设置会话ID
if (currentSession.value?.id) {
webSocketService.setConversationId(currentSession.value.id)
stompWebSocketService.setConversationId(currentSession.value.id)
}
},
onDisconnect: () => {
@@ -281,27 +501,69 @@ export const useChatStore = defineStore('chat', () => {
}
const disconnectWebSocket = () => {
webSocketService.disconnect()
stompWebSocketService.disconnect()
wsConnected.value = false
isConnected.value = false
isTyping.value = false
}
// 初始化
// 初始化聊天 - 参考web项目的实现
const initChat = async () => {
// 如果没有会话,创建一个默认会话
if (sessions.value.length === 0) {
await createSession('与开开的对话')
}
console.log('🚀 初始化聊天功能...')
// 连接WebSocket
await connectWebSocket()
try {
// 1. 首先尝试加载最近消息(优先显示历史数据)
console.log('📨 优先加载最近消息...')
await loadRecentMessages(20)
// 2. 尝试加载用户的历史会话
const currentUserId = authStore.userInfo?.id || authStore.userId
if (currentUserId) {
try {
console.log('📂 尝试加载用户会话,用户ID:', currentUserId)
const userSessions = await chatApi.getUserSessions(currentUserId)
if (userSessions.length > 0) {
sessions.value = userSessions
currentSession.value = userSessions[0]
console.log('✅ 加载到用户会话:', userSessions.length, '个')
}
} catch (error) {
console.warn('⚠️ 加载用户会话失败,继续使用已加载的消息:', error)
}
} else {
console.log('⚠️ 未找到用户ID,无法加载用户会话')
}
// 3. 如果没有会话,创建一个默认会话
if (sessions.value.length === 0) {
console.log('📝 创建默认会话...')
await createSession('与开开的对话')
}
// 4. 如果有特定会话但消息为空,尝试加载会话消息
if (currentSession.value?.id && messages.value.length === 0) {
console.log('📨 尝试加载特定会话消息...')
try {
await loadSessionMessages(currentSession.value.id)
} catch (error) {
console.warn('⚠️ 加载会话消息失败,保持当前消息:', error)
}
}
// 5. 连接WebSocket
console.log('🔌 连接WebSocket...')
await connectWebSocket()
console.log('✅ 聊天功能初始化完成,当前消息数量:', messages.value.length)
} catch (error) {
console.error('❌ 聊天功能初始化失败:', error)
}
}
// 监听会话变化,更新WebSocket会话ID
watch(currentSession, (newSession) => {
if (newSession?.id && wsConnected.value) {
webSocketService.setConversationId(newSession.id)
stompWebSocketService.setConversationId(newSession.id)
}
})
@@ -314,19 +576,36 @@ export const useChatStore = defineStore('chat', () => {
isConnected,
connectionStatus,
wsConnected,
currentMessages,
// 方法
// 基础方法
addMessage,
sendMessage,
createSession,
switchSession,
loadSessionMessages,
deleteSession,
clearMessages,
searchMessages,
initChat,
// 消息加载方法
loadSessionMessages,
loadUserMessages,
loadRecentMessages,
searchMessages,
// WebSocket方法
connectWebSocket,
disconnectWebSocket,
handleWebSocketMessage
handleWebSocketMessage,
// 消息监听方法
onMessage: (callback: (message: any) => void) => {
// 简单的消息监听实现
const originalHandler = handleWebSocketMessage
handleWebSocketMessage = (message: any) => {
originalHandler(message)
callback(message)
}
}
}
})
-157
View File
@@ -1,157 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { DiaryEntry } from '@/types'
export const useDiaryStore = defineStore('diary', () => {
// 日记状态
const entries = ref<DiaryEntry[]>([])
const currentEntry = ref<DiaryEntry | null>(null)
const isLoading = ref(false)
// 方法
const addEntry = async (content: string, mood?: string, tags?: string[]) => {
isLoading.value = true
try {
const newEntry: DiaryEntry = {
id: Date.now().toString(),
content,
mood,
tags: tags || [],
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
}
// TODO: 调用API保存日记
// const response = await diaryApi.createEntry(newEntry)
// 模拟AI回复
setTimeout(() => {
newEntry.aiReply = generateAIReply(content, mood)
entries.value.unshift(newEntry)
isLoading.value = false
}, 1000)
return newEntry
} catch (error) {
console.error('Failed to add diary entry:', error)
isLoading.value = false
throw error
}
}
const updateEntry = async (id: string, updates: Partial<DiaryEntry>) => {
const index = entries.value.findIndex(entry => entry.id === id)
if (index > -1) {
entries.value[index] = {
...entries.value[index],
...updates,
updateTime: new Date().toISOString()
}
// TODO: 调用API更新日记
// await diaryApi.updateEntry(id, updates)
}
}
const deleteEntry = async (id: string) => {
const index = entries.value.findIndex(entry => entry.id === id)
if (index > -1) {
entries.value.splice(index, 1)
// TODO: 调用API删除日记
// await diaryApi.deleteEntry(id)
}
}
const getEntry = (id: string) => {
return entries.value.find(entry => entry.id === id)
}
const searchEntries = (keyword: string) => {
return entries.value.filter(entry =>
entry.content.toLowerCase().includes(keyword.toLowerCase()) ||
entry.tags?.some(tag => tag.toLowerCase().includes(keyword.toLowerCase()))
)
}
const getEntriesByMood = (mood: string) => {
return entries.value.filter(entry => entry.mood === mood)
}
const getEntriesByDateRange = (startDate: string, endDate: string) => {
return entries.value.filter(entry => {
const entryDate = new Date(entry.createTime).toISOString().split('T')[0]
return entryDate >= startDate && entryDate <= endDate
})
}
const loadEntries = async () => {
isLoading.value = true
try {
// TODO: 从API加载日记列表
// const response = await diaryApi.getEntries()
// entries.value = response.data
// 临时模拟数据
entries.value = [
{
id: '1',
content: '今天天气很好,心情也不错。和朋友一起去公园散步,看到了很多美丽的花朵。',
mood: 'happy',
tags: ['散步', '朋友', '公园'],
createTime: new Date(Date.now() - 86400000).toISOString(),
updateTime: new Date(Date.now() - 86400000).toISOString(),
aiReply: '听起来你度过了美好的一天!和朋友一起在大自然中放松是很棒的体验。这样的时光能让我们感受到生活的美好。'
}
]
isLoading.value = false
} catch (error) {
console.error('Failed to load diary entries:', error)
isLoading.value = false
}
}
// 生成AI回复的辅助函数
const generateAIReply = (_content: string, mood?: string) => {
const replies = {
happy: [
'很高兴看到你心情愉快!继续保持这份美好的心情吧。',
'你的快乐感染了我!希望这份喜悦能持续下去。',
'看到你开心,我也很开心。愿你每天都有这样的好心情!'
],
sad: [
'我能感受到你的难过。记住,这只是暂时的,一切都会好起来的。',
'每个人都会有低落的时候,这很正常。我会陪伴你度过这段时光。',
'虽然现在感到难过,但请相信明天会更好。我一直在这里支持你。'
],
neutral: [
'感谢你分享今天的经历。每一天都是独特的,值得被记录。',
'生活就是这样平凡而珍贵。感谢你让我了解你的日常。',
'平静的日子也有它的美好。希望你能在平凡中发现小确幸。'
]
}
const moodReplies = replies[(mood as keyof typeof replies) || 'neutral'] || replies.neutral
return moodReplies[Math.floor(Math.random() * moodReplies.length)]
}
return {
// 状态
entries,
currentEntry,
isLoading,
// 方法
addEntry,
updateEntry,
deleteEntry,
getEntry,
searchEntries,
getEntriesByMood,
getEntriesByDateRange,
loadEntries
}
})
-11
View File
@@ -1,11 +0,0 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
// 导出所有store
export { useUserStore } from './user'
export { useChatStore } from './chat'
export { useDiaryStore } from './diary'
export { useAppStore } from './app'
-157
View File
@@ -1,157 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService, authUtils } from '@/services/auth'
import type { User } from '@/types'
import type { UserInfo, LoginRequest } from '@/types/auth'
export const useUserStore = defineStore('user', () => {
// 用户状态
const user = ref<User | null>(null)
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
const isLoading = ref(false)
const isLoggedIn = computed(() => !!token.value && (!!user.value || !!userInfo.value))
// 方法
const setUser = (userData: User) => {
user.value = userData
}
const setToken = (tokenValue: string) => {
token.value = tokenValue
// 存储到localStorage
if (tokenValue) {
localStorage.setItem('token', tokenValue)
} else {
localStorage.removeItem('token')
}
}
const setUserInfo = (userInfoData: UserInfo | null) => {
userInfo.value = userInfoData
// 存储到localStorage
if (userInfoData) {
localStorage.setItem('userInfo', JSON.stringify(userInfoData))
} else {
localStorage.removeItem('userInfo')
}
}
// 新的登录方法,支持认证服务
const loginWithAuth = async (loginData: LoginRequest) => {
isLoading.value = true
try {
const data = await authService.login(loginData)
setToken(data.accessToken)
setUserInfo(data.userInfo)
return data
} catch (error: any) {
throw error
} finally {
isLoading.value = false
}
}
const login = async (credentials: { username: string; password: string }) => {
try {
// TODO: 调用登录API
// const response = await authApi.login(credentials)
// setToken(response.data.token)
// setUser(response.data.user)
// 临时模拟登录
setToken('mock-token')
setUser({
id: '1',
username: credentials.username,
email: 'user@example.com',
nickname: '用户',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
})
return true
} catch (error) {
console.error('Login failed:', error)
return false
}
}
const logout = async () => {
try {
await authService.logout()
} catch (error) {
console.error('Logout error:', error)
} finally {
// 清除状态和本地存储
user.value = null
userInfo.value = null
setToken('')
authUtils.clearAuth()
}
}
const updateProfile = (profileData: Partial<User>) => {
if (user.value) {
user.value = { ...user.value, ...profileData }
// TODO: 调用更新API
}
}
// 初始化用户状态
const initUser = () => {
const savedToken = authUtils.getToken()
const savedUserInfo = authUtils.getUserInfo()
console.log('初始化用户状态:', { savedToken: !!savedToken, savedUserInfo })
if (savedToken) {
setToken(savedToken)
}
if (savedUserInfo) {
setUserInfo(savedUserInfo)
}
console.log('用户状态初始化完成:', {
token: !!token.value,
userInfo: userInfo.value,
isLoggedIn: isLoggedIn.value
})
}
// 刷新用户信息
const refreshUserInfo = async () => {
if (!token.value) return
try {
const response = await authService.getUserInfo()
if (response.success) {
userInfo.value = response.data
authUtils.setUserInfo(response.data)
}
} catch (error) {
console.error('Refresh user info error:', error)
}
}
return {
// 状态
user,
userInfo,
token,
isLoading,
isLoggedIn,
// 方法
setUser,
setToken,
setUserInfo,
login,
loginWithAuth,
logout,
updateProfile,
initUser,
refreshUserInfo
}
})
+206 -28
View File
@@ -1,83 +1,261 @@
/**
* 认证相关类型定义
*/
// 登录请求
export interface LoginRequest {
/** 账号(支持账号/邮箱/手机号) */
account: string
/** 密码 */
password: string
/** 验证码 */
captcha: string
captchaKey?: string
remember?: boolean
/** 验证码key */
captchaKey: string
/** 记住我 */
rememberMe?: boolean
}
// 注册请求
export interface RegisterRequest {
/** 账号 */
account: string
/** 密码 */
password: string
/** 确认密码 */
confirmPassword: string
/** 用户名 */
username: string
/** 昵称 */
nickname: string
/** 邮箱 */
email: string
/** 手机号 */
phone?: string
email?: string
/** 验证码 */
captcha: string
captchaKey?: string
/** 验证码key */
captchaKey: string
}
// 用户信息
export interface UserInfo {
/** 用户ID */
id: string
/** 账号 */
account: string
username?: string
nickname?: string
/** 用户名 */
username: string
/** 昵称 */
nickname: string
/** 头像 */
avatar?: string
phone?: string
/** 邮箱 */
email?: string
/** 手机号 */
phone?: string
/** 生日 */
birthDate?: string
/** 所在地 */
location?: string
/** 个人简介 */
bio?: string
/** 会员等级 */
memberLevel?: string
/** 使用天数 */
totalDays?: number
/** 成长数据 */
growthStats?: GrowthStats
/** 状态 */
status: number
/** 是否已验证 */
isVerified?: number
/** 创建时间 */
createTime: string
updateTime: string
/** 最后活跃时间 */
lastActiveTime?: string
}
// 登录响应
export interface LoginResponse {
// 成长数据
export interface GrowthStats {
/** 自我感知 */
selfAwareness: number
/** 情绪韧性 */
emotionalResilience: number
/** 行动力 */
actionPower: number
/** 共情力 */
empathy: number
/** 生活热度 */
lifeEnthusiasm: number
}
// 认证响应
export interface AuthResponse {
/** 访问令牌 */
accessToken: string
/** 刷新令牌 */
refreshToken: string
/** 用户信息 */
userInfo: UserInfo
/** 过期时间(秒) */
expiresIn: number
/** 登录时间 */
loginTime: string
}
// 用户信息响应
export interface UserInfoResponse {
/** 用户信息 */
userInfo: UserInfo
}
// 验证码响应
export interface CaptchaResponse {
/** 验证码key */
captchaKey: string
/** 验证码图片(base64 */
captchaImage: string
/** 过期时间(秒) */
expiresIn: number
}
// API响应基础结构
export interface ApiResponse<T = any> {
success: boolean
code: number
message: string
data: T
timestamp: number
}
// 刷新token请求
// 刷新Token请求
export interface RefreshTokenRequest {
/** 刷新令牌 */
refreshToken: string
}
// 修改密码请求
export interface ChangePasswordRequest {
/** 旧密码 */
oldPassword: string
/** 新密码 */
newPassword: string
/** 确认新密码 */
confirmPassword: string
}
// 忘记密码请求
export interface ForgotPasswordRequest {
account: string
captcha: string
captchaKey: string
}
// 重置密码请求
export interface ResetPasswordRequest {
token: string
/** 账号 */
account: string
/** 新密码 */
newPassword: string
/** 确认新密码 */
confirmPassword: string
/** 验证码 */
captcha: string
/** 验证码key */
captchaKey: string
}
// 发送验证码请求
export interface SendCodeRequest {
/** 手机号或邮箱 */
target: string
/** 验证码类型 */
type: 'sms' | 'email'
/** 场景 */
scene: 'register' | 'login' | 'reset_password' | 'change_phone' | 'change_email'
}
// 验证验证码请求
export interface VerifyCodeRequest {
/** 手机号或邮箱 */
target: string
/** 验证码 */
code: string
/** 场景 */
scene: string
}
// 第三方登录请求
export interface OAuthLoginRequest {
/** 平台类型 */
platform: 'wechat' | 'qq' | 'weibo' | 'github'
/** 授权码 */
code: string
/** 状态码 */
state?: string
}
// 绑定第三方账号请求
export interface BindOAuthRequest {
/** 平台类型 */
platform: 'wechat' | 'qq' | 'weibo' | 'github'
/** 授权码 */
code: string
/** 状态码 */
state?: string
}
// 解绑第三方账号请求
export interface UnbindOAuthRequest {
/** 平台类型 */
platform: 'wechat' | 'qq' | 'weibo' | 'github'
}
// 第三方账号信息
export interface OAuthInfo {
/** 平台类型 */
platform: 'wechat' | 'qq' | 'weibo' | 'github'
/** 平台用户ID */
openId: string
/** 平台昵称 */
nickname: string
/** 平台头像 */
avatar: string
/** 绑定时间 */
bindTime: string
}
// 用户权限信息
export interface UserPermission {
/** 角色列表 */
roles: string[]
/** 权限列表 */
permissions: string[]
/** 菜单列表 */
menus: string[]
}
// 登录历史
export interface LoginHistory {
/** ID */
id: string
/** 登录时间 */
loginTime: string
/** 登录IP */
loginIp: string
/** 登录地址 */
loginAddress: string
/** 设备信息 */
deviceInfo: string
/** 浏览器信息 */
browserInfo: string
/** 登录状态 */
status: number
}
// 在线用户信息
export interface OnlineUser {
/** 用户ID */
userId: string
/** 用户名 */
username: string
/** 昵称 */
nickname: string
/** 头像 */
avatar?: string
/** 登录时间 */
loginTime: string
/** 最后活动时间 */
lastActiveTime: string
/** 登录IP */
loginIp: string
/** 设备信息 */
deviceInfo: string
/** 浏览器信息 */
browserInfo: string
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_ENV: string
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
readonly VITE_API_BASE_URL: string
readonly VITE_WS_BASE_URL: string
readonly VITE_UPLOAD_URL: string
readonly VITE_DEBUG: string
readonly VITE_MOCK: string
readonly VITE_APP_DESCRIPTION: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+9
View File
@@ -0,0 +1,9 @@
declare global {
interface Window {
lucide: {
createIcons: () => void;
};
}
}
export {}
+55 -134
View File
@@ -3,178 +3,99 @@ export interface User {
id: string
username: string
email?: string
phone?: string
avatar?: string
nickname?: string
createTime: string
updateTime: string
}
// 聊天消息类型
// 聊天消息类型 - 与后端MessageResponse匹配
export interface ChatMessage {
id: string
content: string
type: 'user' | 'ai'
type: 'user' | 'ai' | 'system'
timestamp: string
sessionId?: string
conversationId?: string // 改为conversationId以匹配后端
sessionId?: string // 保留sessionId作为别名
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
sender?: 'user' | 'ai' | 'system'
isRead?: number
error?: string
sender?: string
role?: 'user' | 'assistant' | 'system' // 用于UI显示
}
// 聊天会话类型
// 聊天会话类型 - 与后端ConversationResponse匹配
export interface ChatSession {
id: string
title: string
userId?: string
userId: string
userType?: string
type?: string
status?: string
createTime: string
updateTime: string
messageCount: number
startTime?: string
endTime?: string
lastActiveTime?: string
}
// 日记条目类型
export interface DiaryEntry {
id: string
content: string
mood?: string
tags?: string[]
createTime: string
updateTime: string
aiReply?: string
}
// 个人信息类型
export interface PersonalInfo {
id: string
userId: string
nickname?: string
age?: number
gender?: string
location?: string
occupation?: string
interests: string[]
skills: string[]
quotes: PersonalQuote[]
updateTime: string
}
// 个人语录类型
export interface PersonalQuote {
id: string
content: string
createTime: string
source?: string
}
// 话题类型
export interface Topic {
id: string
title: string
description?: string
tags?: string[]
createTime: string
updateTime: string
status: 'active' | 'completed' | 'paused'
progress?: number
}
// 生活轨迹事件类型
export interface LifeEvent {
id: string
title: string
description?: string
date: string
type: 'milestone' | 'achievement' | 'memory' | 'goal'
importance: 1 | 2 | 3 | 4 | 5
tags?: string[]
attachments?: string[]
}
// 消息类型
export interface Message {
id: string
title: string
content: string
type: 'system' | 'notification' | 'reminder'
status: 'unread' | 'read'
createTime: string
actionUrl?: string
}
// API响应类型
// API响应基础类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
timestamp: string
success: boolean
}
// 分页参数类型
export interface PaginationParams {
// 分页请求参数
export interface PageParams {
page?: number
size?: number
keyword?: string
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
size: number
total?: number
pages: number
}
// 分页响应类型
export interface PaginatedResponse<T> {
list: T[]
pagination: {
page: number
size: number
total: number
totalPages: number
}
// 登录请求参数
export interface LoginRequest {
account: string
password: string
}
// 导航链接类型
export interface NavLink {
name: string
href: string
icon?: string
children?: NavLink[]
// 登录响应数据
export interface LoginResponse {
accessToken: string
refreshToken: string
user: User
}
// 功能特性类型
export interface Feature {
icon: string
title: string
description: string
image: string
alt: string
// 注册请求参数
export interface RegisterRequest {
username: string
password: string
email?: string
nickname?: string
}
// 心情统计类型
export interface MoodStats {
date: string
mood: string
score: number
// 用户资料更新请求
export interface UserProfileUpdateRequest {
nickname?: string
email?: string
avatar?: string
}
// 表单验证规则类型
export interface ValidationRule {
required?: boolean
message?: string
pattern?: RegExp
min?: number
max?: number
validator?: (rule: any, value: any) => Promise<void>
}
// 主题配置类型
export interface ThemeConfig {
primaryColor: string
secondaryColor: string
backgroundColor: string
textColor: string
borderRadius: string
}
// 环境配置类型
export interface EnvConfig {
apiBaseUrl: string
uploadUrl: string
wsUrl: string
isDevelopment: boolean
isProduction: boolean
// 文件上传响应
export interface UploadResponse {
url: string
filename: string
size: number
type: string
}
+282
View File
@@ -0,0 +1,282 @@
/**
* 错误处理工具
*/
import { ElMessage, ElNotification } from 'element-plus'
// 错误类型枚举
export enum ErrorType {
NETWORK = 'NETWORK',
AUTH = 'AUTH',
VALIDATION = 'VALIDATION',
BUSINESS = 'BUSINESS',
UNKNOWN = 'UNKNOWN'
}
// 错误信息接口
export interface ErrorInfo {
type: ErrorType
code?: string | number
message: string
details?: any
}
/**
* 错误分类器
*/
export class ErrorClassifier {
/**
* 分析错误类型
*/
static classify(error: any): ErrorInfo {
// 网络错误
if (error.code === 'NETWORK_ERROR' || error.message?.includes('Network Error')) {
return {
type: ErrorType.NETWORK,
code: error.code,
message: '网络连接失败,请检查网络设置'
}
}
// 认证错误
if (error.status === 401 || error.code === 401) {
return {
type: ErrorType.AUTH,
code: 401,
message: '登录已过期,请重新登录'
}
}
// 权限错误
if (error.status === 403 || error.code === 403) {
return {
type: ErrorType.AUTH,
code: 403,
message: '没有权限访问该资源'
}
}
// 验证错误
if (error.status === 400 || error.code === 400) {
return {
type: ErrorType.VALIDATION,
code: 400,
message: error.message || '请求参数错误'
}
}
// 服务器错误
if (error.status >= 500 || error.code >= 500) {
return {
type: ErrorType.NETWORK,
code: error.status || error.code,
message: '服务器内部错误,请稍后重试'
}
}
// 业务错误
if (error.message) {
return {
type: ErrorType.BUSINESS,
code: error.code,
message: error.message,
details: error
}
}
// 未知错误
return {
type: ErrorType.UNKNOWN,
message: '发生未知错误,请稍后重试',
details: error
}
}
}
/**
* 错误处理器
*/
export class ErrorHandler {
/**
* 处理错误
*/
static handle(error: any, options: {
showMessage?: boolean
showNotification?: boolean
logError?: boolean
} = {}) {
const {
showMessage = true,
showNotification = false,
logError = true
} = options
const errorInfo = ErrorClassifier.classify(error)
// 记录错误日志
if (logError) {
console.error('错误处理:', {
type: errorInfo.type,
code: errorInfo.code,
message: errorInfo.message,
details: errorInfo.details,
originalError: error
})
}
// 显示错误消息
if (showMessage) {
this.showErrorMessage(errorInfo)
}
// 显示错误通知
if (showNotification) {
this.showErrorNotification(errorInfo)
}
return errorInfo
}
/**
* 显示错误消息
*/
private static showErrorMessage(errorInfo: ErrorInfo) {
switch (errorInfo.type) {
case ErrorType.NETWORK:
ElMessage.error({
message: errorInfo.message,
duration: 5000
})
break
case ErrorType.AUTH:
ElMessage.warning({
message: errorInfo.message,
duration: 3000
})
break
case ErrorType.VALIDATION:
ElMessage.warning({
message: errorInfo.message,
duration: 3000
})
break
case ErrorType.BUSINESS:
ElMessage.error({
message: errorInfo.message,
duration: 4000
})
break
default:
ElMessage.error({
message: errorInfo.message,
duration: 4000
})
}
}
/**
* 显示错误通知
*/
private static showErrorNotification(errorInfo: ErrorInfo) {
ElNotification.error({
title: '错误提示',
message: errorInfo.message,
duration: 5000
})
}
/**
* 处理认证相关错误
*/
static handleAuthError(error: any) {
const errorInfo = this.handle(error, {
showMessage: true,
logError: true
})
// 如果是认证错误,可能需要跳转到登录页
if (errorInfo.type === ErrorType.AUTH && errorInfo.code === 401) {
// 清除本地认证信息
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
// 延迟跳转,让用户看到错误消息
setTimeout(() => {
window.location.href = '/login'
}, 2000)
}
return errorInfo
}
/**
* 处理API请求错误
*/
static handleApiError(error: any, context?: string) {
const contextMessage = context ? `${context}: ` : ''
const errorInfo = this.handle(error, {
showMessage: true,
logError: true
})
// 添加上下文信息
if (context) {
console.error(`${contextMessage}`, errorInfo)
}
return errorInfo
}
/**
* 处理表单验证错误
*/
static handleValidationError(error: any, fieldName?: string) {
let message = error.message || '表单验证失败'
if (fieldName) {
message = `${fieldName}: ${message}`
}
ElMessage.warning({
message,
duration: 3000
})
return {
type: ErrorType.VALIDATION,
message,
details: error
}
}
}
/**
* 错误处理装饰器
*/
export function handleError(options?: {
showMessage?: boolean
showNotification?: boolean
logError?: boolean
}) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
descriptor.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args)
} catch (error) {
ErrorHandler.handle(error, options)
throw error
}
}
return descriptor
}
}
// 导出常用方法
export const handleApiError = ErrorHandler.handleApiError
export const handleAuthError = ErrorHandler.handleAuthError
export const handleValidationError = ErrorHandler.handleValidationError
-229
View File
@@ -1,229 +0,0 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
// 配置dayjs
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
// 时间格式化
export const formatTime = {
// 相对时间
relative: (date: string | Date) => dayjs(date).fromNow(),
// 标准格式
standard: (date: string | Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
// 日期格式
date: (date: string | Date) => dayjs(date).format('YYYY-MM-DD'),
// 时间格式
time: (date: string | Date) => dayjs(date).format('HH:mm:ss'),
// 友好格式
friendly: (date: string | Date) => {
const now = dayjs()
const target = dayjs(date)
const diffDays = now.diff(target, 'day')
if (diffDays === 0) {
return target.format('HH:mm')
} else if (diffDays === 1) {
return '昨天 ' + target.format('HH:mm')
} else if (diffDays < 7) {
return target.format('M月D日 HH:mm')
} else {
return target.format('YYYY年M月D日')
}
}
}
// 防抖函数
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => func(...args), wait)
}
}
// 节流函数
export const throttle = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let lastTime = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastTime >= wait) {
lastTime = now
func(...args)
}
}
}
// 生成唯一ID
export const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 深拷贝
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T
}
if (typeof obj === 'object') {
const cloned = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
return obj
}
// 文件大小格式化
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 数字格式化
export const formatNumber = (num: number): string => {
if (num < 1000) return num.toString()
if (num < 10000) return (num / 1000).toFixed(1) + 'K'
if (num < 100000000) return (num / 10000).toFixed(1) + '万'
return (num / 100000000).toFixed(1) + '亿'
}
// 颜色工具
export const colorUtils = {
// 十六进制转RGB
hexToRgb: (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null
},
// RGB转十六进制
rgbToHex: (r: number, g: number, b: number) => {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
},
// 获取随机颜色
random: () => {
return '#' + Math.floor(Math.random() * 16777215).toString(16)
}
}
// 本地存储工具
export const storage = {
set: (key: string, value: any) => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error('Storage set error:', error)
}
},
get: <T = any>(key: string, defaultValue?: T): T | null => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : defaultValue || null
} catch (error) {
console.error('Storage get error:', error)
return defaultValue || null
}
},
remove: (key: string) => {
try {
localStorage.removeItem(key)
} catch (error) {
console.error('Storage remove error:', error)
}
},
clear: () => {
try {
localStorage.clear()
} catch (error) {
console.error('Storage clear error:', error)
}
}
}
// URL工具
export const urlUtils = {
// 获取查询参数
getQuery: (name: string): string | null => {
const urlParams = new URLSearchParams(window.location.search)
return urlParams.get(name)
},
// 设置查询参数
setQuery: (params: Record<string, string>) => {
const url = new URL(window.location.href)
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
window.history.replaceState({}, '', url.toString())
},
// 删除查询参数
removeQuery: (name: string) => {
const url = new URL(window.location.href)
url.searchParams.delete(name)
window.history.replaceState({}, '', url.toString())
}
}
// 设备检测
export const deviceUtils = {
isMobile: () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),
isIOS: () => /iPad|iPhone|iPod/.test(navigator.userAgent),
isAndroid: () => /Android/.test(navigator.userAgent),
isWechat: () => /MicroMessenger/i.test(navigator.userAgent)
}
// 验证工具
export const validators = {
email: (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
phone: (phone: string) => /^1[3-9]\d{9}$/.test(phone),
password: (password: string) => password.length >= 6,
url: (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* MessageService 测试工具
* 用于验证消息服务是否正常工作
*/
import MessageService from '@/services/message'
export const testMessageService = async () => {
console.log('🧪 开始测试 MessageService...')
try {
// 测试获取最近消息
console.log('📝 测试获取最近消息...')
const recentMessages = await MessageService.getRecentMessages({ limit: 5 })
console.log('✅ 最近消息:', recentMessages)
// 测试分页获取消息
console.log('📄 测试分页获取消息...')
const pageMessages = await MessageService.getUserMessages(1, 10)
console.log('✅ 分页消息:', pageMessages)
console.log('🎉 MessageService 测试完成!')
return true
} catch (error) {
console.error('❌ MessageService 测试失败:', error)
return false
}
}
// 在开发环境下可以在控制台调用 window.testMessageService() 进行测试
if (typeof window !== 'undefined') {
(window as any).testMessageService = testMessageService
}
+223 -93
View File
@@ -1,118 +1,248 @@
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import router from '@/router'
/**
* HTTP请求工具
* 基于axios封装的统一请求实例
*/
// 获取API基础URL
const getApiBaseUrl = () => {
// 开发环境使用代理
if (import.meta.env.DEV) {
return '/api'
}
// 生产环境使用环境变量
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { envConfig } from '@/config/env'
// 请求响应接口
export interface ApiResponse<T = any> {
code: number
message: string
data: T
success: boolean
timestamp: number
}
// 请求配置接口
export interface RequestConfig extends AxiosRequestConfig {
// 是否显示loading
showLoading?: boolean
// 是否显示错误消息
showError?: boolean
// 是否需要token
needToken?: boolean
}
// 创建axios实例
const request: AxiosInstance = axios.create({
baseURL: getApiBaseUrl(),
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
console.log('API Base URL:', getApiBaseUrl())
// 请求拦截器
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从localStorage获取token
const token = localStorage.getItem('token')
if (token && config.headers) {
// 在请求头中添加Authorization
config.headers.Authorization = `Bearer ${token}`
const createAxiosInstance = (): AxiosInstance => {
const instance = axios.create({
baseURL: envConfig.apiBaseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
console.log('发送请求:', {
url: config.url,
method: config.method,
hasToken: !!token,
headers: config.headers
})
// 请求拦截器
instance.interceptors.request.use(
(config: any) => {
// 添加token
const token = localStorage.getItem('access_token')
console.log('🔑 请求拦截器 - Token状态:', {
hasToken: !!token,
tokenPreview: token ? `${token.substring(0, 20)}...` : 'null',
url: config.url,
needToken: config.needToken
})
return config
},
(error) => {
console.error('请求拦截器错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
const { data } = response
// 标准后端格式: { code, message, data, timestamp }
if (typeof data === 'object' && data !== null && 'code' in data) {
if (data.code !== 200) {
message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
if (token && config.needToken !== false) {
config.headers.Authorization = `Bearer ${token}`
console.log('🔑 已添加Authorization头')
} else {
console.log('🔑 未添加Authorization头 - 原因:', !token ? '无token' : 'needToken=false')
}
// 只返回data字段, 兼容验证码等所有接口
return data.data
// 添加请求ID用于追踪
config.headers['X-Request-ID'] = generateRequestId()
// 打印请求日志
if (envConfig.debug) {
console.log('🚀 Request:', {
url: config.url,
method: config.method,
params: config.params,
data: config.data,
headers: config.headers
})
}
return config
},
(error: AxiosError) => {
console.error('❌ Request Error:', error)
return Promise.reject(error)
}
// 兼容极特殊情况(如验证码图片流等)
return data
},
(error) => {
console.error('响应拦截器错误:', error)
)
if (error.response) {
const { status, data } = error.response
// 响应拦截器
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
switch (status) {
// 打印响应日志
if (envConfig.debug) {
console.log('✅ Response:', {
url: response.config.url,
status: response.status,
data: data
})
}
// 检查业务状态码
if (data.code === 200 || data.success) {
return data
}
// 处理业务错误
const errorMessage = data.message || '请求失败'
// 特殊错误码处理
switch (data.code) {
case 401:
// token过期或无效
message.error('登录已过期,请重新登录')
// 清除本地存储的用户信息
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 清除store中的用户信息
const userStore = useUserStore()
userStore.setToken('')
userStore.setUserInfo(null)
// 跳转到登录页
router.push('/login')
console.warn('🚫 业务层401错误:', errorMessage)
// 只有在非登录接口时才处理401
if (!response.config.url?.includes('/auth/login')) {
handleUnauthorized()
} else {
ElMessage.error(errorMessage)
}
break
case 403:
message.error('没有权限访问该资源')
ElMessage.error('没有权限访问该资源')
break
case 404:
message.error('请求的资源不存在')
ElMessage.error('请求的资源不存在')
break
case 500:
message.error('服务器内部错误')
ElMessage.error('服务器内部错误')
break
default:
message.error(data?.message || '请求失败')
ElMessage.error(errorMessage)
}
} else if (error.request) {
message.error('网络连接失败,请检查网络')
} else {
message.error('请求配置错误')
}
return Promise.reject(error)
return Promise.reject(new Error(errorMessage))
},
(error: AxiosError) => {
console.error('❌ Response Error:', error)
let errorMessage = '网络请求失败'
if (error.response) {
// 服务器响应错误
const { status, data } = error.response
switch (status) {
case 400:
errorMessage = '请求参数错误'
break
case 401:
errorMessage = '未授权,请重新登录'
console.warn('🚫 HTTP层401错误')
// 只有在非登录接口时才处理401
if (!error.config?.url?.includes('/auth/login')) {
handleUnauthorized()
}
break
case 403:
errorMessage = '拒绝访问'
break
case 404:
errorMessage = '请求地址不存在'
break
case 408:
errorMessage = '请求超时'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务不可用'
break
case 504:
errorMessage = '网关超时'
break
default:
errorMessage = (data as any)?.message || `请求失败 (${status})`
}
} else if (error.request) {
// 网络错误
errorMessage = '网络连接失败,请检查网络'
} else {
// 其他错误
errorMessage = error.message || '请求配置错误'
}
ElMessage.error(errorMessage)
return Promise.reject(error)
}
)
return instance
}
// 处理未授权
const handleUnauthorized = () => {
console.warn('🚫 收到401未授权响应')
// 检查当前页面是否是登录页,避免在登录页面重复处理
if (window.location.pathname === '/login') {
console.log('🚫 当前在登录页面,不处理401错误')
return
}
)
// 不立即清除认证信息,而是提示用户
ElMessageBox.confirm(
'登录状态已过期,请重新登录',
'提示',
{
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
// 用户确认后才清除认证信息
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
// 跳转到登录页
window.location.href = '/login'
}).catch(() => {
// 用户取消,不清除认证信息,让用户继续操作
console.log('🚫 用户取消重新登录')
})
}
// 生成请求ID
const generateRequestId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
}
// 创建请求实例
export const request = createAxiosInstance()
// 导出请求方法
export const http = {
get: <T = any>(url: string, config?: RequestConfig) =>
request.get<any, ApiResponse<T>>(url, config),
post: <T = any>(url: string, data?: any, config?: RequestConfig) =>
request.post<any, ApiResponse<T>>(url, data, config),
put: <T = any>(url: string, data?: any, config?: RequestConfig) =>
request.put<any, ApiResponse<T>>(url, data, config),
delete: <T = any>(url: string, config?: RequestConfig) =>
request.delete<any, ApiResponse<T>>(url, config),
patch: <T = any>(url: string, data?: any, config?: RequestConfig) =>
request.patch<any, ApiResponse<T>>(url, data, config)
}
export default request
-141
View File
@@ -1,141 +0,0 @@
/**
* WebSocket连接测试工具
* 用于测试WebSocket连接和消息发送功能
*/
import webSocketService from '@/services/websocket'
export class WebSocketTester {
private isConnected = false
private testResults: string[] = []
/**
* 运行WebSocket连接测试
*/
async runConnectionTest(): Promise<boolean> {
this.testResults = []
this.log('开始WebSocket连接测试...')
try {
// 测试连接
await webSocketService.connect('test_user_' + Date.now(), {
onConnect: () => {
this.isConnected = true
this.log('✅ WebSocket连接成功')
},
onDisconnect: () => {
this.isConnected = false
this.log('❌ WebSocket连接断开')
},
onError: (error) => {
this.log(`❌ WebSocket错误: ${error.userMessage || error.message || '未知错误'}`)
},
onMessage: (message) => {
this.log(`📨 收到消息: ${message.type} - ${message.content}`)
}
})
// 等待连接建立
await this.waitForConnection(5000)
if (this.isConnected) {
this.log('✅ 连接测试通过')
return true
} else {
this.log('❌ 连接测试失败')
return false
}
} catch (error) {
this.log(`❌ 连接测试异常: ${error}`)
return false
}
}
/**
* 测试消息发送
*/
async testMessageSending(): Promise<boolean> {
if (!this.isConnected) {
this.log('❌ 未连接,无法测试消息发送')
return false
}
try {
this.log('开始测试消息发送...')
// 设置测试会话ID
webSocketService.setConversationId('test_conversation_' + Date.now())
// 发送测试消息
webSocketService.sendChatMessage('这是一条测试消息')
this.log('✅ 消息发送成功')
return true
} catch (error) {
this.log(`❌ 消息发送失败: ${error}`)
return false
}
}
/**
* 断开连接测试
*/
testDisconnection(): void {
this.log('开始测试断开连接...')
webSocketService.disconnect()
this.log('✅ 断开连接完成')
}
/**
* 获取测试结果
*/
getTestResults(): string[] {
return [...this.testResults]
}
/**
* 清空测试结果
*/
clearResults(): void {
this.testResults = []
}
/**
* 记录测试日志
*/
private log(message: string): void {
const timestamp = new Date().toLocaleTimeString()
const logMessage = `[${timestamp}] ${message}`
this.testResults.push(logMessage)
console.log(logMessage)
}
/**
* 等待连接建立
*/
private waitForConnection(timeout: number): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now()
const checkConnection = () => {
if (this.isConnected) {
resolve()
} else if (Date.now() - startTime > timeout) {
reject(new Error('连接超时'))
} else {
setTimeout(checkConnection, 100)
}
}
checkConnection()
})
}
}
// 导出测试实例
export const wsTest = new WebSocketTester()
// 开发环境下添加到全局对象,方便调试
if (import.meta.env.DEV) {
(window as any).wsTest = wsTest
}
+39
View File
@@ -0,0 +1,39 @@
<template>
<div class="analysis-page">
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">情绪分析</h1>
<p class="text-gray-600">深度分析情绪数据发现情绪规律</p>
</div>
<div class="card">
<div class="text-center text-gray-500 py-12">
<el-icon class="text-4xl mb-4">
<TrendCharts />
</el-icon>
<p>情绪分析功能开发中...</p>
<p class="text-sm mt-2">这里将展示情绪趋势雷达图热力图等可视化内容</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { TrendCharts } from '@element-plus/icons-vue'
</script>
<style scoped>
.analysis-page {
min-height: 100vh;
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
background-attachment: fixed;
}
.analysis-page .container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
margin-top: 2rem;
margin-bottom: 2rem;
}
</style>
-644
View File
@@ -1,644 +0,0 @@
<template>
<div class="chat-history-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">聊天历史</h1>
</div>
<a-button type="text" @click="showSearchModal = true" class="search-btn">
<SearchOutlined />
搜索
</a-button>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<!-- 会话列表 -->
<div class="sessions-list">
<div
v-for="session in chatStore.sessions"
:key="session.id"
class="session-item"
@click="viewSession(session)"
>
<div class="session-avatar">
<a-avatar :src="kaikaiAvatar" :size="48" />
</div>
<div class="session-content">
<div class="session-header">
<h3 class="session-title">{{ session.title }}</h3>
<span class="session-time">{{ formatTime.friendly(session.updateTime) }}</span>
</div>
<div class="session-info">
<span class="message-count">{{ session.messageCount }} 条消息</span>
<span class="session-date">{{ formatTime.date(session.createTime) }}</span>
</div>
</div>
<div class="session-actions">
<a-dropdown @click.stop>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="renameSession(session)">
<EditOutlined />
重命名
</a-menu-item>
<a-menu-item @click="exportSession(session)">
<DownloadOutlined />
导出
</a-menu-item>
<a-menu-item @click="deleteSession(session.id)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="chatStore.sessions.length === 0" class="empty-state">
<a-empty
description="暂无聊天记录"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
>
<a-button type="primary" @click="$router.push('/chat')">
开始新对话
</a-button>
</a-empty>
</div>
</div>
</main>
<!-- 搜索模态框 -->
<a-modal
v-model:open="showSearchModal"
title="搜索聊天记录"
:footer="null"
width="600px"
>
<div class="search-content">
<a-input-search
v-model:value="searchKeyword"
placeholder="输入关键词搜索..."
@search="handleSearch"
size="large"
style="margin-bottom: 16px"
/>
<div class="search-filters">
<a-date-picker
v-model:value="searchDate"
placeholder="按日期筛选"
style="width: 100%; margin-bottom: 16px"
/>
</div>
<div class="search-results" v-if="searchResults.length > 0">
<h4>搜索结果 ({{ searchResults.length }})</h4>
<div class="results-list">
<div
v-for="result in searchResults"
:key="result.id"
class="result-item"
@click="viewSearchResult(result)"
>
<div class="result-content">
<div class="result-text">{{ result.content }}</div>
<div class="result-meta">
<span class="result-type">{{ result.type === 'user' ? '我' : '开开' }}</span>
<span class="result-time">{{ formatTime.standard(result.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="searchKeyword && hasSearched" class="no-results">
<a-empty description="未找到相关消息" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</a-modal>
<!-- 会话详情模态框 -->
<a-modal
v-model:open="showSessionModal"
:title="selectedSession?.title"
:footer="null"
width="800px"
:body-style="{ maxHeight: '60vh', overflow: 'auto' }"
>
<div v-if="selectedSession" class="session-detail">
<div class="session-info-header">
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatTime.standard(selectedSession.createTime) }}</span>
</div>
<div class="info-item">
<span class="info-label">最后更新</span>
<span class="info-value">{{ formatTime.standard(selectedSession.updateTime) }}</span>
</div>
<div class="info-item">
<span class="info-label">消息数量</span>
<span class="info-value">{{ selectedSession.messageCount }} </span>
</div>
</div>
<div class="session-messages">
<div
v-for="message in sessionMessages"
:key="message.id"
class="message-item"
:class="{ 'user-message': message.type === 'user' }"
>
<div class="message-avatar" v-if="message.type === 'ai'">
<a-avatar :src="kaikaiAvatar" :size="32" />
</div>
<div class="message-bubble">
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatTime.friendly(message.timestamp) }}</div>
</div>
</div>
</div>
<div class="session-actions">
<a-button @click="continueSession(selectedSession)" type="primary">
继续对话
</a-button>
<a-button @click="exportSession(selectedSession)">
导出记录
</a-button>
</div>
</div>
</a-modal>
<!-- 重命名模态框 -->
<a-modal
v-model:open="showRenameModal"
title="重命名会话"
@ok="confirmRename"
@cancel="cancelRename"
>
<a-input
v-model:value="newSessionName"
placeholder="请输入新的会话名称"
:maxlength="50"
show-count
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
ArrowLeftOutlined,
SearchOutlined,
MoreOutlined,
EditOutlined,
DownloadOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { useChatStore } from '@/stores'
import { formatTime } from '@/utils'
import type { ChatSession, ChatMessage } from '@/types'
import type { Dayjs } from 'dayjs'
const chatStore = useChatStore()
// 响应式数据
const showSearchModal = ref(false)
const showSessionModal = ref(false)
const showRenameModal = ref(false)
const searchKeyword = ref('')
const searchDate = ref<Dayjs | null>(null)
const hasSearched = ref(false)
const selectedSession = ref<ChatSession | null>(null)
const sessionToRename = ref<ChatSession | null>(null)
const newSessionName = ref('')
// 开开头像
const kaikaiAvatar = 'https://r2.flowith.net/files/o/1752574406770-thoughtful_kaikai_character_generation_index_1@1024x1024.png'
// 模拟会话消息数据
const sessionMessages = ref<ChatMessage[]>([])
// 搜索结果
const searchResults = ref<ChatMessage[]>([])
// 计算属性
// const filteredSessions = computed(() => {
// return chatStore.sessions.sort((a, b) =>
// new Date(b.updateTime).getTime() - new Date(a.updateTime).getTime()
// )
// })
// 方法
const viewSession = (session: ChatSession) => {
selectedSession.value = session
loadSessionMessages(session.id)
showSessionModal.value = true
}
const loadSessionMessages = async (sessionId: string) => {
try {
// TODO: 从API加载会话消息
// const messages = await chatApi.getSessionMessages(sessionId)
// 模拟消息数据
sessionMessages.value = [
{
id: '1',
content: '你好,开开!',
type: 'user',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
sessionId
},
{
id: '2',
content: '你好!很高兴见到你,有什么我可以帮助你的吗?',
type: 'ai',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000 + 30000).toISOString(),
sessionId
}
]
} catch (error) {
message.error('加载消息失败')
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
hasSearched.value = true
try {
// TODO: 调用搜索API
// const results = await chatApi.searchMessages(searchKeyword.value, searchDate.value)
// 模拟搜索结果
searchResults.value = [
{
id: '1',
content: `这是包含"${searchKeyword.value}"的消息内容...`,
type: 'user',
timestamp: new Date().toISOString(),
sessionId: '1'
}
]
} catch (error) {
message.error('搜索失败')
}
}
const viewSearchResult = (result: ChatMessage) => {
// 跳转到对应的会话
const session = chatStore.sessions.find(s => s.id === result.sessionId)
if (session) {
showSearchModal.value = false
viewSession(session)
}
}
const renameSession = (session: ChatSession) => {
sessionToRename.value = session
newSessionName.value = session.title
showRenameModal.value = true
}
const confirmRename = async () => {
if (!newSessionName.value.trim()) {
message.warning('请输入会话名称')
return
}
if (sessionToRename.value) {
try {
// await chatStore.updateSessionTitle(sessionToRename.value.id, newSessionName.value.trim())
console.log('重命名会话:', sessionToRename.value.id, newSessionName.value.trim())
message.success('重命名成功')
showRenameModal.value = false
} catch (error) {
message.error('重命名失败')
}
}
}
const cancelRename = () => {
sessionToRename.value = null
newSessionName.value = ''
}
const exportSession = (_session: ChatSession) => {
// TODO: 实现导出功能
message.info('导出功能开发中...')
}
const deleteSession = async (sessionId: string) => {
try {
await chatStore.deleteSession(sessionId)
message.success('会话删除成功')
} catch (error) {
message.error('删除失败')
}
}
const continueSession = (session: ChatSession) => {
chatStore.switchSession(session.id)
showSessionModal.value = false
// 跳转到聊天页面
// router.push('/chat')
}
// 组件挂载
onMounted(() => {
// 初始化数据
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.chat-history-page {
min-height: 100vh;
background: $light-gray;
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 800px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.search-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.sessions-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.session-item {
background: white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
display: flex;
gap: $spacing-md;
cursor: pointer;
transition: all $transition-normal;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-1px);
}
.session-avatar {
flex-shrink: 0;
}
.session-content {
flex: 1;
min-width: 0;
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-xs;
.session-title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-time {
font-size: $font-size-sm;
color: $text-medium;
flex-shrink: 0;
margin-left: $spacing-md;
}
}
.session-info {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
.message-count {
color: $tech-blue;
}
}
}
.session-actions {
flex-shrink: 0;
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-xxl;
}
// 搜索模态框样式
.search-content {
.search-results {
h4 {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
.results-list {
max-height: 300px;
overflow-y: auto;
}
.result-item {
padding: $spacing-md;
border-radius: $border-radius-md;
cursor: pointer;
transition: background-color $transition-normal;
&:hover {
background: $light-gray;
}
.result-content {
.result-text {
color: $text-dark;
line-height: 1.5;
margin-bottom: $spacing-xs;
}
.result-meta {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
.result-type {
font-weight: $font-weight-medium;
}
}
}
}
}
.no-results {
text-align: center;
padding: $spacing-xl;
}
}
// 会话详情模态框样式
.session-detail {
.session-info-header {
display: flex;
flex-direction: column;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
padding: $spacing-md;
background: $light-gray;
border-radius: $border-radius-md;
.info-item {
display: flex;
gap: $spacing-sm;
.info-label {
font-weight: $font-weight-medium;
color: $text-medium;
min-width: 80px;
}
.info-value {
color: $text-dark;
}
}
}
.session-messages {
max-height: 400px;
overflow-y: auto;
margin-bottom: $spacing-lg;
.message-item {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
&.user-message {
flex-direction: row-reverse;
.message-bubble {
background: $tech-blue;
color: white;
border-radius: 18px 18px 4px 18px;
}
}
.message-avatar {
flex-shrink: 0;
}
.message-bubble {
background: white;
border-radius: 18px 18px 18px 4px;
padding: $spacing-md;
box-shadow: $shadow-sm;
max-width: 70%;
.message-text {
line-height: 1.5;
margin-bottom: $spacing-xs;
}
.message-time {
font-size: $font-size-xs;
opacity: 0.7;
}
}
}
}
.session-actions {
display: flex;
gap: $spacing-md;
padding-top: $spacing-md;
border-top: 1px solid #f0f0f0;
}
}
</style>
+457 -1121
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
<template>
<div class="bg-light-gray font-sans text-text-dark flex flex-col h-screen antialiased">
<!-- Header -->
<header class="bg-white shadow-md z-10 flex-shrink-0">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-3">
<router-link to="/chat" class="text-text-medium hover:text-tech-blue transition-colors">
<i data-lucide="chevron-left" class="w-6 h-6"></i>
</router-link>
<h1 class="text-lg font-bold text-text-dark">聊天记录</h1>
</div>
<button
@click="toggleSearch"
class="text-text-medium hover:text-tech-blue transition-colors"
>
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
</header>
<!-- Search Bar -->
<div v-if="searchOpen" class="bg-white border-b border-gray-200 px-4 py-3">
<div class="relative">
<input
v-model="searchKeyword"
@input="handleSearch"
type="text"
placeholder="搜索聊天记录..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-tech-blue focus:border-transparent"
>
<i data-lucide="search" class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"></i>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-tech-blue mx-auto mb-2"></div>
<p class="text-text-medium">加载聊天记录中...</p>
</div>
</div>
<!-- Chat History List -->
<main v-else id="history-list" class="flex-1 overflow-y-auto p-4 lg:p-6 space-y-3">
<!-- 无数据状态 -->
<div v-if="chatHistoryData.length === 0" class="text-center py-12">
<i data-lucide="message-circle" class="w-16 h-16 text-gray-300 mx-auto mb-4"></i>
<p class="text-text-medium">暂无聊天记录</p>
<router-link to="/chat" class="inline-block mt-4 px-6 py-2 bg-tech-blue text-white rounded-lg hover:bg-blue-600 transition-colors">
开始聊天
</router-link>
</div>
<!-- 聊天记录列表 -->
<div
v-for="item in chatHistoryData"
:key="item.id"
@click="goToChat(item.id)"
class="block bg-white p-4 rounded-xl shadow-sm hover:shadow-md hover:border-tech-blue/50 border border-transparent transition-all duration-300 group cursor-pointer"
>
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<p class="text-sm text-text-medium mb-1 group-hover:text-tech-blue transition-colors">{{ formatDate(item.createTime) }}</p>
<p class="font-medium text-text-dark truncate">{{ item.title }}</p>
<p class="text-xs text-gray-500 mt-1">{{ item.messageCount }} 条消息</p>
</div>
<i data-lucide="chevron-right" class="w-5 h-5 text-gray-300 group-hover:text-tech-blue transition-colors flex-shrink-0 ml-4"></i>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore && !loading" class="text-center py-4">
<button
@click="loadMore"
class="px-6 py-2 text-tech-blue border border-tech-blue rounded-lg hover:bg-tech-blue hover:text-white transition-colors"
>
加载更多
</button>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { chatApi } from '@/services/chat'
import { messageApi } from '@/services/message'
import { useAuthStore } from '@/stores/auth'
import type { ChatSession } from '@/types'
const router = useRouter()
const authStore = useAuthStore()
// 响应式数据
const searchOpen = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
const chatHistoryData = ref<ChatSession[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
// 方法
const toggleSearch = () => {
searchOpen.value = !searchOpen.value
if (!searchOpen.value) {
searchKeyword.value = ''
loadChatHistory() // 重新加载完整列表
}
}
const formatDate = (dateInput: string | Date) => {
try {
let date: Date
if (dateInput instanceof Date) {
if (isNaN(dateInput.getTime())) {
return '日期无效'
}
date = dateInput
} else if (typeof dateInput === 'string') {
// 精确匹配后端格式 "2025-07-26 22:09:10"
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateInput)) {
const dateStr = dateInput.replace(' ', 'T')
date = new Date(dateStr)
} else {
date = new Date(dateInput)
}
} else {
return '未知日期'
}
if (isNaN(date.getTime())) {
return '日期无效'
}
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
return '今天'
} else if (diffDays === 2) {
return '昨天'
} else if (diffDays <= 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
} catch (error) {
return '日期错误'
}
}
const goToChat = (sessionId: string) => {
router.push(`/chat?session=${sessionId}`)
}
const loadChatHistory = async (page: number = 1) => {
try {
loading.value = true
console.log('📂 加载聊天历史:', { page, pageSize: pageSize.value })
// 获取当前用户ID
const currentUserId = authStore.userInfo?.id || authStore.userId
if (!currentUserId) {
console.warn('⚠️ 未找到用户ID,无法加载聊天历史')
return
}
// 获取用户的所有会话
const userSessions = await chatApi.getUserSessions(currentUserId)
if (page === 1) {
chatHistoryData.value = userSessions
} else {
chatHistoryData.value.push(...userSessions)
}
hasMore.value = userSessions.length === pageSize.value
console.log('✅ 聊天历史加载完成:', chatHistoryData.value.length)
} catch (error) {
console.error('❌ 加载聊天历史失败:', error)
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!loading.value && hasMore.value) {
currentPage.value++
loadChatHistory(currentPage.value)
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
loadChatHistory()
return
}
try {
loading.value = true
console.log('🔍 搜索聊天记录:', searchKeyword.value)
// 使用消息搜索API来查找相关会话
const searchResults = await messageApi.searchUserMessages(searchKeyword.value, 50)
// 从搜索结果中提取唯一的会话ID
const conversationIds = [...new Set(searchResults.data?.map((msg: any) => msg.conversationId) || [])]
// 过滤出匹配的会话
const filteredSessions = chatHistoryData.value.filter(session =>
conversationIds.includes(session.id) ||
session.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
chatHistoryData.value = filteredSessions
console.log('✅ 搜索完成,找到', filteredSessions.length, '个会话')
} catch (error) {
console.error('❌ 搜索失败:', error)
} finally {
loading.value = false
}
}
// 生命周期
onMounted(async () => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 延迟初始化图标
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons()
}
}, 100)
// 加载聊天历史
await loadChatHistory()
})
</script>
<style scoped>
/* 导入原始样式变量 */
:root {
--tech-blue: #4A90E2;
--warm-orange: #F5A623;
--white: #FFFFFF;
--light-gray: #F7F8FA;
--text-dark: #333333;
--text-medium: #888888;
}
/* 应用原始样式类 */
.bg-tech-blue { background-color: var(--tech-blue); }
.bg-warm-orange { background-color: var(--warm-orange); }
.bg-light-gray { background-color: var(--light-gray); }
.text-tech-blue { color: var(--tech-blue); }
.text-text-dark { color: var(--text-dark); }
.text-text-medium { color: var(--text-medium); }
.border-tech-blue { border-color: var(--tech-blue); }
.chat-history-page {
font-family: 'Noto Sans SC', sans-serif;
background-color: var(--light-gray);
color: var(--text-dark);
display: flex;
flex-direction: column;
height: 100vh;
}
/* 全局样式 */
body {
font-family: 'Noto Sans SC', sans-serif;
}
</style>
-695
View File
@@ -1,695 +0,0 @@
<template>
<div class="dashboard-page">
<!-- 头部 -->
<header class="page-header">
<div class="header-content">
<div class="header-left">
<a-button type="text" @click="$router.back()" class="back-btn">
<ArrowLeftOutlined />
</a-button>
<h1 class="page-title">个人展板</h1>
</div>
<a-button type="text" @click="editMode = !editMode" class="edit-btn">
<EditOutlined />
{{ editMode ? '完成' : '编辑' }}
</a-button>
</div>
</header>
<!-- 主要内容 -->
<main class="page-main">
<div class="container">
<div class="dashboard-grid">
<!-- 基础信息卡片 -->
<a-card class="info-card" title="基础信息">
<template #extra>
<UserOutlined class="card-icon" />
</template>
<div class="basic-info">
<div class="info-item">
<span class="info-label">昵称</span>
<span class="info-value">{{ personalInfo.nickname || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">年龄</span>
<span class="info-value">{{ personalInfo.age || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">职业</span>
<span class="info-value">{{ personalInfo.occupation || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">地区</span>
<span class="info-value">{{ personalInfo.location || '未设置' }}</span>
</div>
</div>
<a-button v-if="editMode" type="link" @click="showBasicInfoModal = true">
编辑信息
</a-button>
</a-card>
<!-- 心情统计卡片 -->
<a-card class="chart-card" title="近期心情统计">
<template #extra>
<BarChartOutlined class="card-icon" />
</template>
<div class="chart-container">
<canvas ref="moodChartRef" class="mood-chart"></canvas>
</div>
</a-card>
<!-- 兴趣爱好卡片 -->
<a-card class="interests-card" title="兴趣爱好">
<template #extra>
<div class="card-extra">
<HeartOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddInterestModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="tags-container">
<a-tag
v-for="interest in personalInfo.interests"
:key="interest"
:closable="editMode"
@close="removeInterest(interest)"
color="blue"
class="interest-tag"
>
{{ interest }}
</a-tag>
<a-tag
v-if="personalInfo.interests.length === 0"
class="empty-tag"
>
暂无兴趣爱好
</a-tag>
</div>
<a-button
v-if="!editMode"
type="link"
@click="exploreInterests"
class="explore-btn"
>
<StarOutlined />
探索可能发展的爱好
</a-button>
</a-card>
<!-- 生活技能卡片 -->
<a-card class="skills-card" title="生活技能">
<template #extra>
<div class="card-extra">
<ToolOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddSkillModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="tags-container">
<a-tag
v-for="skill in personalInfo.skills"
:key="skill"
:closable="editMode"
@close="removeSkill(skill)"
color="green"
class="skill-tag"
>
{{ skill }}
</a-tag>
<a-tag
v-if="personalInfo.skills.length === 0"
class="empty-tag"
>
暂无技能记录
</a-tag>
</div>
<a-button
v-if="!editMode"
type="link"
@click="exploreSkills"
class="explore-btn"
>
<ExperimentOutlined />
探索可能发展的技能
</a-button>
</a-card>
<!-- 个人语录卡片 -->
<a-card class="quotes-card full-width" title="个人语录">
<template #extra>
<div class="card-extra">
<MessageOutlined class="card-icon" />
<a-button
v-if="editMode"
type="text"
size="small"
@click="showAddQuoteModal = true"
>
<PlusOutlined />
</a-button>
</div>
</template>
<div class="quotes-container">
<div
v-for="quote in personalInfo.quotes"
:key="quote.id"
class="quote-item"
>
<div class="quote-content">
<blockquote class="quote-text">"{{ quote.content }}"</blockquote>
<div class="quote-meta">
<span class="quote-date">{{ formatTime.date(quote.createTime) }}</span>
<span v-if="quote.source" class="quote-source">来源{{ quote.source }}</span>
</div>
</div>
<a-button
v-if="editMode"
type="text"
size="small"
danger
@click="removeQuote(quote.id)"
>
<DeleteOutlined />
</a-button>
</div>
<div v-if="personalInfo.quotes.length === 0" class="empty-quotes">
<a-empty description="暂无个人语录" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</a-card>
</div>
<!-- 添加自定义模块按钮 -->
<div class="add-module-section" v-if="editMode">
<a-button type="dashed" size="large" class="add-module-btn">
<PlusOutlined />
自由添加模块
</a-button>
</div>
</div>
</main>
<!-- 基础信息编辑模态框 -->
<a-modal
v-model:open="showBasicInfoModal"
title="编辑基础信息"
@ok="saveBasicInfo"
@cancel="resetBasicInfo"
>
<a-form :model="basicInfoForm" layout="vertical">
<a-form-item label="昵称">
<a-input v-model:value="basicInfoForm.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="年龄">
<a-input-number
v-model:value="basicInfoForm.age"
:min="1"
:max="120"
placeholder="请输入年龄"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="职业">
<a-input v-model:value="basicInfoForm.occupation" placeholder="请输入职业" />
</a-form-item>
<a-form-item label="地区">
<a-input v-model:value="basicInfoForm.location" placeholder="请输入地区" />
</a-form-item>
</a-form>
</a-modal>
<!-- 添加兴趣模态框 -->
<a-modal
v-model:open="showAddInterestModal"
title="添加兴趣爱好"
@ok="addInterest"
@cancel="newInterest = ''"
>
<a-input
v-model:value="newInterest"
placeholder="请输入兴趣爱好"
@press-enter="addInterest"
/>
</a-modal>
<!-- 添加技能模态框 -->
<a-modal
v-model:open="showAddSkillModal"
title="添加生活技能"
@ok="addSkill"
@cancel="newSkill = ''"
>
<a-input
v-model:value="newSkill"
placeholder="请输入生活技能"
@press-enter="addSkill"
/>
</a-modal>
<!-- 添加语录模态框 -->
<a-modal
v-model:open="showAddQuoteModal"
title="添加个人语录"
@ok="addQuote"
@cancel="resetQuoteForm"
>
<a-form :model="quoteForm" layout="vertical">
<a-form-item label="语录内容" required>
<a-textarea
v-model:value="quoteForm.content"
placeholder="请输入语录内容"
:rows="3"
/>
</a-form-item>
<a-form-item label="来源">
<a-input v-model:value="quoteForm.source" placeholder="请输入来源(可选)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import {
ArrowLeftOutlined,
EditOutlined,
UserOutlined,
BarChartOutlined,
HeartOutlined,
ToolOutlined,
MessageOutlined,
PlusOutlined,
DeleteOutlined,
StarOutlined,
ExperimentOutlined,
} from '@ant-design/icons-vue'
import { Empty, message } from 'ant-design-vue'
import { Chart, registerables } from 'chart.js'
import { formatTime } from '@/utils'
import type { PersonalInfo, PersonalQuote } from '@/types'
// 注册Chart.js组件
Chart.register(...registerables)
// 响应式数据
const editMode = ref(false)
const showBasicInfoModal = ref(false)
const showAddInterestModal = ref(false)
const showAddSkillModal = ref(false)
const showAddQuoteModal = ref(false)
const newInterest = ref('')
const newSkill = ref('')
const moodChartRef = ref<HTMLCanvasElement>()
let moodChart: Chart | null = null
console.log('moodChart initialized:', moodChart) // 避免未使用警告
// 个人信息数据
const personalInfo = reactive<PersonalInfo>({
id: '1',
userId: '1',
nickname: '开心用户',
age: 25,
occupation: '软件工程师',
location: '北京',
interests: ['阅读', '旅行', '摄影', '音乐'],
skills: ['编程', '设计', '写作', '烹饪'],
quotes: [
{
id: '1',
content: '生活不是等待暴风雨过去,而是学会在雨中跳舞',
createTime: new Date().toISOString(),
source: '电影台词'
}
],
updateTime: new Date().toISOString()
})
// 表单数据
const basicInfoForm = reactive({
nickname: '',
age: undefined as number | undefined,
occupation: '',
location: ''
})
const quoteForm = reactive({
content: '',
source: ''
})
// 方法
const saveBasicInfo = () => {
Object.assign(personalInfo, basicInfoForm)
showBasicInfoModal.value = false
message.success('基础信息保存成功')
}
const resetBasicInfo = () => {
basicInfoForm.nickname = personalInfo.nickname || ''
basicInfoForm.age = personalInfo.age
basicInfoForm.occupation = personalInfo.occupation || ''
basicInfoForm.location = personalInfo.location || ''
}
const addInterest = () => {
if (newInterest.value.trim() && !personalInfo.interests.includes(newInterest.value.trim())) {
personalInfo.interests.push(newInterest.value.trim())
newInterest.value = ''
showAddInterestModal.value = false
message.success('兴趣爱好添加成功')
}
}
const removeInterest = (interest: string) => {
const index = personalInfo.interests.indexOf(interest)
if (index > -1) {
personalInfo.interests.splice(index, 1)
}
}
const addSkill = () => {
if (newSkill.value.trim() && !personalInfo.skills.includes(newSkill.value.trim())) {
personalInfo.skills.push(newSkill.value.trim())
newSkill.value = ''
showAddSkillModal.value = false
message.success('生活技能添加成功')
}
}
const removeSkill = (skill: string) => {
const index = personalInfo.skills.indexOf(skill)
if (index > -1) {
personalInfo.skills.splice(index, 1)
}
}
const addQuote = () => {
if (quoteForm.content.trim()) {
const newQuote: PersonalQuote = {
id: Date.now().toString(),
content: quoteForm.content.trim(),
createTime: new Date().toISOString(),
source: quoteForm.source.trim() || undefined
}
personalInfo.quotes.unshift(newQuote)
resetQuoteForm()
showAddQuoteModal.value = false
message.success('个人语录添加成功')
}
}
const removeQuote = (id: string) => {
const index = personalInfo.quotes.findIndex(q => q.id === id)
if (index > -1) {
personalInfo.quotes.splice(index, 1)
}
}
const resetQuoteForm = () => {
quoteForm.content = ''
quoteForm.source = ''
}
const exploreInterests = () => {
message.info('兴趣探索功能开发中...')
}
const exploreSkills = () => {
message.info('技能探索功能开发中...')
}
// 初始化心情图表
const initMoodChart = () => {
nextTick(() => {
if (moodChartRef.value) {
const ctx = moodChartRef.value.getContext('2d')
if (ctx) {
moodChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
datasets: [{
label: '心情指数',
data: [7, 8, 6, 9, 7, 8, 9],
borderColor: '#4A90E2',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 10,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
},
x: {
grid: {
display: false
}
}
}
}
})
}
}
})
}
// 组件挂载
onMounted(() => {
resetBasicInfo()
initMoodChart()
})
</script>
<style lang="scss" scoped>
@use "@/assets/styles/variables.scss" as *;
.dashboard-page {
min-height: 100vh;
background: $light-gray;
}
.page-header {
background: white;
box-shadow: $shadow-sm;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
max-width: 1200px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: $spacing-md;
}
.back-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
}
.edit-btn {
color: $text-medium;
&:hover {
color: $tech-blue;
}
}
.page-main {
padding: $spacing-lg;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-lg;
@media (min-width: $breakpoint-lg) {
grid-template-columns: repeat(2, 1fr);
}
}
.full-width {
@media (min-width: $breakpoint-lg) {
grid-column: 1 / -1;
}
}
.card-icon {
color: $tech-blue;
}
.card-extra {
display: flex;
align-items: center;
gap: $spacing-sm;
}
// 基础信息卡片
.basic-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-md;
}
.info-item {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.info-label {
font-size: $font-size-sm;
color: $text-medium;
font-weight: $font-weight-medium;
}
.info-value {
color: $text-dark;
}
// 图表卡片
.chart-container {
height: 200px;
position: relative;
}
.mood-chart {
width: 100% !important;
height: 100% !important;
}
// 标签容器
.tags-container {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
margin-bottom: $spacing-md;
min-height: 32px;
}
.interest-tag,
.skill-tag {
margin: 0;
}
.empty-tag {
color: $text-medium;
background: transparent;
border: 1px dashed #d9d9d9;
}
.explore-btn {
padding: 0;
height: auto;
font-size: $font-size-sm;
}
// 语录卡片
.quotes-container {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.quote-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: $spacing-md;
background: rgba(74, 144, 226, 0.05);
border-radius: $border-radius-md;
border-left: 3px solid $tech-blue;
}
.quote-content {
flex: 1;
}
.quote-text {
font-style: italic;
color: $text-dark;
margin: 0 0 $spacing-xs 0;
line-height: 1.5;
}
.quote-meta {
display: flex;
gap: $spacing-md;
font-size: $font-size-sm;
color: $text-medium;
}
.empty-quotes {
text-align: center;
padding: $spacing-xl;
}
// 添加模块区域
.add-module-section {
margin-top: $spacing-xl;
text-align: center;
}
.add-module-btn {
width: 100%;
height: 80px;
font-size: $font-size-lg;
border-radius: $border-radius-lg;
border: 2px dashed #d9d9d9;
&:hover {
border-color: $tech-blue;
color: $tech-blue;
}
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="p-6 max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">WebSocket连接测试</h1>
<!-- 连接状态 -->
<div class="mb-6 p-4 rounded-lg" :class="statusClass">
<h2 class="text-lg font-semibold mb-2">连接状态</h2>
<p>状态: {{ connectionStatus }}</p>
<p>WebSocket URL: {{ wsUrl }}</p>
<p>用户ID: {{ userId }}</p>
</div>
<!-- 控制按钮 -->
<div class="mb-6 space-x-4">
<button
@click="connect"
:disabled="isConnected"
class="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
连接
</button>
<button
@click="disconnect"
:disabled="!isConnected"
class="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
断开
</button>
<button
@click="clearLogs"
class="px-4 py-2 bg-gray-500 text-white rounded"
>
清空日志
</button>
</div>
<!-- 发送消息 -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">发送消息</h2>
<div class="flex space-x-2">
<input
v-model="messageInput"
@keyup.enter="sendMessage"
placeholder="输入测试消息..."
class="flex-1 px-3 py-2 border rounded"
:disabled="!isConnected"
>
<button
@click="sendMessage"
:disabled="!isConnected || !messageInput.trim()"
class="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
发送
</button>
</div>
</div>
<!-- 日志 -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">连接日志</h2>
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="mb-1 text-sm">
<span class="text-gray-500">{{ log.timestamp }}</span>
<span :class="log.type === 'error' ? 'text-red-600' : log.type === 'success' ? 'text-green-600' : 'text-blue-600'">
[{{ log.type.toUpperCase() }}]
</span>
{{ log.message }}
</div>
</div>
</div>
<!-- 接收到的消息 -->
<div>
<h2 class="text-lg font-semibold mb-2">接收到的消息</h2>
<div class="bg-gray-100 p-4 rounded-lg h-64 overflow-y-auto">
<div v-for="(message, index) in receivedMessages" :key="index" class="mb-2 p-2 bg-white rounded">
<div class="text-xs text-gray-500 mb-1">{{ message.timestamp }}</div>
<pre class="text-sm">{{ JSON.stringify(message.data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { stompWebSocketService as webSocketService, type WebSocketMessage } from '@/services/stomp-websocket'
import { envConfig } from '@/config/env'
// 响应式数据
const connectionStatus = ref('DISCONNECTED')
const isConnected = computed(() => connectionStatus.value === 'CONNECTED')
const messageInput = ref('')
const logs = ref<Array<{timestamp: string, type: string, message: string}>>([])
const receivedMessages = ref<Array<{timestamp: string, data: any}>>([])
// 配置
const wsUrl = `${envConfig.apiBaseUrl.replace('http', 'ws').replace('/api', '')}/ws/chat`
const userId = ref(`test_user_${Date.now()}`)
// 计算样式类
const statusClass = computed(() => {
switch (connectionStatus.value) {
case 'CONNECTED':
return 'bg-green-100 border border-green-300'
case 'CONNECTING':
return 'bg-yellow-100 border border-yellow-300'
case 'ERROR':
return 'bg-red-100 border border-red-300'
default:
return 'bg-gray-100 border border-gray-300'
}
})
// 添加日志
const addLog = (type: string, message: string) => {
logs.value.push({
timestamp: new Date().toLocaleTimeString(),
type,
message
})
}
// 连接WebSocket
const connect = async () => {
try {
addLog('info', '开始连接WebSocket...')
await webSocketService.connect(userId.value, {
onConnect: () => {
addLog('success', 'WebSocket连接成功')
connectionStatus.value = 'CONNECTED'
},
onDisconnect: () => {
addLog('info', 'WebSocket连接断开')
connectionStatus.value = 'DISCONNECTED'
},
onError: (error) => {
addLog('error', `WebSocket错误: ${error.userMessage || error.message || '未知错误'}`)
connectionStatus.value = 'ERROR'
},
onStatusChange: (status) => {
connectionStatus.value = status
addLog('info', `连接状态变更: ${status}`)
},
onMessage: (message: WebSocketMessage) => {
addLog('success', `收到消息: ${message.type} - ${message.content}`)
receivedMessages.value.push({
timestamp: new Date().toLocaleTimeString(),
data: message
})
}
})
} catch (error) {
addLog('error', `连接失败: ${error}`)
}
}
// 断开连接
const disconnect = () => {
webSocketService.disconnect()
addLog('info', '主动断开连接')
}
// 发送消息
const sendMessage = () => {
if (!messageInput.value.trim()) return
try {
webSocketService.sendChatMessage(messageInput.value.trim())
addLog('info', `发送消息: ${messageInput.value.trim()}`)
messageInput.value = ''
} catch (error) {
addLog('error', `发送消息失败: ${error}`)
}
}
// 清空日志
const clearLogs = () => {
logs.value = []
receivedMessages.value = []
}
// 生命周期
onMounted(() => {
addLog('info', 'WebSocket测试页面已加载')
addLog('info', `WebSocket URL: ${wsUrl}`)
})
onUnmounted(() => {
if (isConnected.value) {
disconnect()
}
})
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="debug-page p-8">
<h1 class="text-2xl font-bold mb-6">环境变量调试页面</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 当前环境信息 -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-4">当前环境信息</h2>
<div class="space-y-2">
<div><strong>环境类型:</strong> {{ currentEnv }}</div>
<div><strong>环境名称:</strong> {{ envConfig.name }}</div>
<div><strong>调试模式:</strong> {{ envConfig.debug ? '开启' : '关闭' }}</div>
<div><strong>Mock模式:</strong> {{ envConfig.mock ? '开启' : '关闭' }}</div>
</div>
</div>
<!-- API配置信息 -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-4">API配置信息</h2>
<div class="space-y-2">
<div><strong>API基础URL:</strong> {{ envConfig.apiBaseUrl }}</div>
<div><strong>WebSocket URL:</strong> {{ envConfig.wsBaseUrl }}</div>
<div><strong>上传URL:</strong> {{ envConfig.uploadUrl }}</div>
</div>
</div>
<!-- 原始环境变量 -->
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
<h2 class="text-lg font-semibold mb-4">原始环境变量</h2>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ JSON.stringify(rawEnv, null, 2) }}</pre>
</div>
<!-- API测试 -->
<div class="bg-white rounded-lg shadow p-6 md:col-span-2">
<h2 class="text-lg font-semibold mb-4">API连接测试</h2>
<div class="space-y-4">
<div class="flex space-x-4">
<button
@click="testApiConnection"
:disabled="testing"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{{ testing ? '测试中...' : '测试API连接' }}
</button>
<router-link
to="/debug/websocket"
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
WebSocket测试
</router-link>
<router-link
to="/chat"
class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600"
>
聊天页面
</router-link>
</div>
<div v-if="testResult" class="mt-4">
<h3 class="font-semibold mb-2">测试结果:</h3>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ testResult }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { envConfig, getCurrentEnv } from '@/config/env'
import AuthService from '@/services/auth'
// 响应式数据
const testing = ref(false)
const testResult = ref('')
// 计算属性
const currentEnv = computed(() => getCurrentEnv())
const rawEnv = computed(() => import.meta.env)
/**
* 测试API连接
*/
const testApiConnection = async () => {
testing.value = true
testResult.value = ''
try {
console.log('开始测试API连接...')
console.log('使用的API基础URL:', envConfig.apiBaseUrl)
const response = await AuthService.getCaptcha()
testResult.value = JSON.stringify({
success: true,
message: 'API连接成功',
apiUrl: envConfig.apiBaseUrl,
response: {
captchaKey: response.captchaKey,
imageLength: response.captchaImage?.length || 0,
expiresIn: response.expiresIn,
imagePreview: response.captchaImage?.substring(0, 100) + '...' // 显示前100个字符
}
}, null, 2)
} catch (error: any) {
console.error('API连接测试失败:', error)
testResult.value = JSON.stringify({
success: false,
message: 'API连接失败',
apiUrl: envConfig.apiBaseUrl,
error: {
message: error.message,
status: error.status,
code: error.code,
config: error.config ? {
url: error.config.url,
method: error.config.method,
baseURL: error.config.baseURL
} : null
}
}, null, 2)
} finally {
testing.value = false
}
}
// 组件挂载时输出调试信息
onMounted(() => {
console.log('=== 环境变量调试信息 ===')
console.log('当前环境:', currentEnv.value)
console.log('环境配置:', envConfig)
console.log('原始环境变量:', import.meta.env)
console.log('========================')
})
</script>
<style scoped>
.debug-page {
min-height: 100vh;
background-color: #f5f5f5;
}
</style>

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