feat: 完成情绪博物馆项目重构和功能增强 - 新增日记评论和帖子功能 - 重构前端架构,优化用户体验 - 完善WebSocket通信机制 - 更新项目文档和部署配置
This commit is contained in:
Generated
+1
-1
File diff suppressed because one or more lines are too long
Generated
+10
-1
@@ -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>
|
||||
Generated
+25
@@ -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.id,user_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));
|
||||
}
|
||||
}
|
||||
@@ -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开发中的主要方面,包括命名规范、代码结构、注释规范、异常处理、日志记录、数据库操作、接口设计、缓存使用、异步处理、单元测试、安全规范和版本控制等。遵循这些规范可以确保代码的质量、可维护性和可扩展性。
|
||||
遵循这些规范可以确保代码的质量、可维护性和可扩展性,提高团队开发效率。
|
||||
@@ -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. **高可用**: 完善的监控和异常处理机制
|
||||
|
||||
该方案可以作为项目开发的技术指导文档,确保项目的技术实现符合最佳实践。
|
||||
@@ -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层实现
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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系统
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -4,6 +4,6 @@
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": true
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请联系开发团队。
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+5211
-4110
File diff suppressed because it is too large
Load Diff
+51
-34
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+14
-129
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
@use "@/assets/styles/variables.scss" as *;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
+19
-12
@@ -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')
|
||||
})
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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_token,WebSocket将以访客身份连接')
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
lucide: {
|
||||
createIcons: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
+55
-134
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user