mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-06 12:00:44 +08:00
Compare commits
7 Commits
feature/fi
...
e5b386cdc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b386cdc2 | ||
|
|
179699dd66 | ||
|
|
ef39b5fedf | ||
|
|
e13ee1ca46 | ||
|
|
09f1435e33 | ||
|
|
7e7cebbbe7 | ||
|
|
5c1031c57c |
@@ -217,11 +217,7 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return postService
|
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
|
||||||
.defaultListPosts(ids, tids, page, pageSize)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/recent")
|
@GetMapping("/recent")
|
||||||
@@ -269,11 +265,7 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return postService
|
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
|
||||||
.listPostsByViews(ids, tids, page, pageSize)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/latest-reply")
|
@GetMapping("/latest-reply")
|
||||||
@@ -305,8 +297,7 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
|
||||||
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/featured")
|
@GetMapping("/featured")
|
||||||
@@ -333,10 +324,6 @@ public class PostController {
|
|||||||
// if (auth != null) {
|
// if (auth != null) {
|
||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
return postService
|
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
|
||||||
.listFeaturedPosts(ids, tids, page, pageSize)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public class UserController {
|
|||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final SubscriptionService subscriptionService;
|
private final SubscriptionService subscriptionService;
|
||||||
private final LevelService levelService;
|
private final LevelService levelService;
|
||||||
|
private final PostReadService postReadService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
@@ -53,6 +54,9 @@ public class UserController {
|
|||||||
@Value("${app.user.tags-limit:50}")
|
@Value("${app.user.tags-limit:50}")
|
||||||
private int defaultTagsLimit;
|
private int defaultTagsLimit;
|
||||||
|
|
||||||
|
@Value("${app.user.read-posts-limit:50}")
|
||||||
|
private int defaultReadPostsLimit;
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@SecurityRequirement(name = "JWT")
|
@SecurityRequirement(name = "JWT")
|
||||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||||
@@ -211,6 +215,33 @@ public class UserController {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{identifier}/read-posts")
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
@Operation(summary = "User read posts", description = "Get post read history (self only)")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Post read history",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
|
||||||
|
)
|
||||||
|
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
|
||||||
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
if (auth == null || !auth.getName().equals(user.getUsername())) {
|
||||||
|
return ResponseEntity.status(403).body(java.util.List.of());
|
||||||
|
}
|
||||||
|
int l = limit != null ? limit : defaultReadPostsLimit;
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
postReadService
|
||||||
|
.getRecentReadsByUser(user.getUsername(), l)
|
||||||
|
.stream()
|
||||||
|
.map(userMapper::toPostReadDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/hot-posts")
|
@GetMapping("/{identifier}/hot-posts")
|
||||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||||
@ApiResponse(
|
@ApiResponse(
|
||||||
|
|||||||
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** DTO for a user's post read record. */
|
||||||
|
@Data
|
||||||
|
public class PostReadDto {
|
||||||
|
|
||||||
|
private PostMetaDto post;
|
||||||
|
private LocalDateTime lastReadAt;
|
||||||
|
}
|
||||||
@@ -48,6 +48,38 @@ public class PostMapper {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PostSummaryDto> toListDtos(List<Post> posts) {
|
||||||
|
if (posts == null || posts.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
|
||||||
|
return posts
|
||||||
|
.stream()
|
||||||
|
.map(post -> {
|
||||||
|
PostSummaryDto dto = new PostSummaryDto();
|
||||||
|
applyListFields(post, dto);
|
||||||
|
List<User> participants = participantsMap.get(post.getId());
|
||||||
|
if (participants != null) {
|
||||||
|
dto.setParticipants(
|
||||||
|
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dto.setParticipants(List.of());
|
||||||
|
}
|
||||||
|
dto.setReactions(List.of());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostSummaryDto toListDto(Post post) {
|
||||||
|
PostSummaryDto dto = new PostSummaryDto();
|
||||||
|
applyListFields(post, dto);
|
||||||
|
dto.setParticipants(List.of());
|
||||||
|
dto.setReactions(List.of());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
public PostDetailDto toDetailDto(Post post, String viewer) {
|
public PostDetailDto toDetailDto(Post post, String viewer) {
|
||||||
PostDetailDto dto = new PostDetailDto();
|
PostDetailDto dto = new PostDetailDto();
|
||||||
applyCommon(post, dto);
|
applyCommon(post, dto);
|
||||||
@@ -61,6 +93,25 @@ public class PostMapper {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyListFields(Post post, PostSummaryDto dto) {
|
||||||
|
dto.setId(post.getId());
|
||||||
|
dto.setTitle(post.getTitle());
|
||||||
|
dto.setContent(post.getContent());
|
||||||
|
dto.setCreatedAt(post.getCreatedAt());
|
||||||
|
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
|
||||||
|
dto.setCategory(categoryMapper.toDto(post.getCategory()));
|
||||||
|
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
|
||||||
|
dto.setViews(post.getViews());
|
||||||
|
dto.setCommentCount(post.getCommentCount());
|
||||||
|
dto.setStatus(post.getStatus());
|
||||||
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
|
dto.setLastReplyAt(post.getLastReplyAt());
|
||||||
|
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||||
|
dto.setClosed(post.isClosed());
|
||||||
|
dto.setVisibleScope(post.getVisibleScope());
|
||||||
|
dto.setType(post.getType());
|
||||||
|
}
|
||||||
|
|
||||||
private void applyCommon(Post post, PostSummaryDto dto) {
|
private void applyCommon(Post post, PostSummaryDto dto) {
|
||||||
dto.setId(post.getId());
|
dto.setId(post.getId());
|
||||||
dto.setTitle(post.getTitle());
|
dto.setTitle(post.getTitle());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.openisle.mapper;
|
|||||||
import com.openisle.dto.*;
|
import com.openisle.dto.*;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
|
import com.openisle.model.PostRead;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -115,4 +116,11 @@ public class UserMapper {
|
|||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PostReadDto toPostReadDto(PostRead read) {
|
||||||
|
PostReadDto dto = new PostReadDto();
|
||||||
|
dto.setPost(toMetaDto(read.getPost()));
|
||||||
|
dto.setLastReadAt(read.getLastReadAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
|
|||||||
@org.springframework.data.repository.query.Param("post") Post post
|
@org.springframework.data.repository.query.Param("post") Post post
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@org.springframework.data.jpa.repository.Query(
|
||||||
|
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
|
||||||
|
)
|
||||||
|
java.util.List<Object[]> findDistinctAuthorsByPostIds(
|
||||||
|
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
|
||||||
|
);
|
||||||
|
|
||||||
@org.springframework.data.jpa.repository.Query(
|
@org.springframework.data.jpa.repository.Query(
|
||||||
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
|
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.PostRead;
|
import com.openisle.model.PostRead;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||||
|
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
|
||||||
long countByUser(User user);
|
long countByUser(User user);
|
||||||
void deleteByPost(Post post);
|
void deleteByPost(Post post);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusOrderByPinnedAtDescViewsDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusOrderByPinnedAtDescLastReplyAtDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||||
PostStatus status,
|
PostStatus status,
|
||||||
LocalDateTime createdAt
|
LocalDateTime createdAt
|
||||||
@@ -43,6 +45,16 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
PostStatus status,
|
PostStatus status,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||||
|
List<Category> categories,
|
||||||
|
PostStatus status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
List<Category> categories,
|
||||||
|
PostStatus status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
||||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
||||||
@@ -132,6 +144,26 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
|
||||||
)
|
)
|
||||||
@@ -174,6 +206,28 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
@Param("categories") List<Category> categories,
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
@Param("categories") List<Category> categories,
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ import com.openisle.service.NotificationService;
|
|||||||
import com.openisle.service.PointService;
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.service.SubscriptionService;
|
import com.openisle.service.SubscriptionService;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -316,6 +320,37 @@ public class CommentService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
|
||||||
|
if (posts == null || posts.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
|
||||||
|
List<Long> postIds = new ArrayList<>(posts.size());
|
||||||
|
for (Post post : posts) {
|
||||||
|
postIds.add(post.getId());
|
||||||
|
LinkedHashSet<User> set = new LinkedHashSet<>();
|
||||||
|
set.add(post.getAuthor());
|
||||||
|
map.put(post.getId(), set);
|
||||||
|
}
|
||||||
|
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
|
||||||
|
Long postId = (Long) row[0];
|
||||||
|
User author = (User) row[1];
|
||||||
|
LinkedHashSet<User> set = map.get(postId);
|
||||||
|
if (set != null) {
|
||||||
|
set.add(author);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<Long, List<User>> result = new HashMap<>(map.size());
|
||||||
|
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
|
||||||
|
List<User> list = new ArrayList<>(entry.getValue());
|
||||||
|
if (list.size() > limit) {
|
||||||
|
list = list.subList(0, limit);
|
||||||
|
}
|
||||||
|
result.put(entry.getKey(), list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||||
log.debug("getCommentsByIds called for ids {}", ids);
|
log.debug("getCommentsByIds called for ids {}", ids);
|
||||||
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -43,6 +46,14 @@ public class PostReadService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PostRead> getRecentReadsByUser(String username, int limit) {
|
||||||
|
User user = userRepository
|
||||||
|
.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
|
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
public long countReads(String username) {
|
public long countReads(String username) {
|
||||||
User user = userRepository
|
User user = userRepository
|
||||||
.findByUsername(username)
|
.findByUsername(username)
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ public class PostService {
|
|||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new HashSet<>(tags));
|
post.setTags(new HashSet<>(tags));
|
||||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||||
|
post.setLastReplyAt(LocalDateTime.now());
|
||||||
|
|
||||||
// 什么都没设置的情况下,默认为ALL
|
// 什么都没设置的情况下,默认为ALL
|
||||||
if (Objects.isNull(postVisibleScopeType)) {
|
if (Objects.isNull(postVisibleScopeType)) {
|
||||||
@@ -809,9 +810,10 @@ public class PostService {
|
|||||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||||
|
|
||||||
java.util.List<Post> posts;
|
java.util.List<Post> posts;
|
||||||
|
Pageable pageable = buildPageable(page, pageSize);
|
||||||
|
|
||||||
if (!hasCategories && !hasTags) {
|
if (!hasCategories && !hasTags) {
|
||||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
|
||||||
} else if (hasCategories) {
|
} else if (hasCategories) {
|
||||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||||
if (categories.isEmpty()) {
|
if (categories.isEmpty()) {
|
||||||
@@ -822,16 +824,18 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
categories,
|
categories,
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
|
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||||
categories,
|
categories,
|
||||||
PostStatus.PUBLISHED
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -839,10 +843,15 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
|
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
tags,
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
tags.size(),
|
||||||
|
pageable
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return paginate(sortByPinnedAndViews(posts), page, pageSize);
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||||||
@@ -859,9 +868,13 @@ public class PostService {
|
|||||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||||
|
|
||||||
java.util.List<Post> posts;
|
java.util.List<Post> posts;
|
||||||
|
Pageable pageable = buildPageable(page, pageSize);
|
||||||
|
|
||||||
if (!hasCategories && !hasTags) {
|
if (!hasCategories && !hasTags) {
|
||||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
|
);
|
||||||
} else if (hasCategories) {
|
} else if (hasCategories) {
|
||||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||||
if (categories.isEmpty()) {
|
if (categories.isEmpty()) {
|
||||||
@@ -872,16 +885,18 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
categories,
|
categories,
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
|
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
categories,
|
categories,
|
||||||
PostStatus.PUBLISHED
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -889,14 +904,15 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
|
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByCategories(
|
public List<Post> listPostsByCategories(
|
||||||
@@ -1394,6 +1410,13 @@ public class PostService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Pageable buildPageable(Integer page, Integer pageSize) {
|
||||||
|
if (page == null || pageSize == null) {
|
||||||
|
return Pageable.unpaged();
|
||||||
|
}
|
||||||
|
return PageRequest.of(page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
|
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
|
||||||
if (page == null || pageSize == null) {
|
if (page == null || pageSize == null) {
|
||||||
return posts;
|
return posts;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Backfill last_reply_at for posts without comments to preserve latest-reply ordering
|
||||||
|
UPDATE posts
|
||||||
|
SET last_reply_at = created_at
|
||||||
|
WHERE last_reply_at IS NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS post_reads (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
last_read_at DATETIME(6) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
|
||||||
|
KEY IDX_post_reads_user (user_id),
|
||||||
|
KEY IDX_post_reads_post (post_id),
|
||||||
|
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -30,62 +30,62 @@ services:
|
|||||||
- dev_local_backend
|
- dev_local_backend
|
||||||
- prod
|
- prod
|
||||||
|
|
||||||
# OpenSearch Service
|
# # OpenSearch Service
|
||||||
opensearch:
|
# opensearch:
|
||||||
user: "1000:1000"
|
# user: "1000:1000"
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: opensearch.Dockerfile
|
# dockerfile: opensearch.Dockerfile
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
# container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||||
environment:
|
# environment:
|
||||||
- cluster.name=os-single
|
# - cluster.name=os-single
|
||||||
- node.name=os-node-1
|
# - node.name=os-node-1
|
||||||
- discovery.type=single-node
|
# - discovery.type=single-node
|
||||||
- bootstrap.memory_lock=true
|
# - bootstrap.memory_lock=true
|
||||||
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
# - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||||
- DISABLE_SECURITY_PLUGIN=true
|
# - DISABLE_SECURITY_PLUGIN=true
|
||||||
- cluster.blocks.create_index=false
|
# - cluster.blocks.create_index=false
|
||||||
ulimits:
|
# ulimits:
|
||||||
memlock: { soft: -1, hard: -1 }
|
# memlock: { soft: -1, hard: -1 }
|
||||||
nofile: { soft: 65536, hard: 65536 }
|
# nofile: { soft: 65536, hard: 65536 }
|
||||||
volumes:
|
# volumes:
|
||||||
- opensearch-data:/usr/share/opensearch/data
|
# - opensearch-data:/usr/share/opensearch/data
|
||||||
- opensearch-snapshots:/snapshots
|
# - opensearch-snapshots:/snapshots
|
||||||
ports:
|
# ports:
|
||||||
- "${OPENSEARCH_PORT:-9200}:9200"
|
# - "${OPENSEARCH_PORT:-9200}:9200"
|
||||||
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
# - "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test:
|
# test:
|
||||||
- CMD-SHELL
|
# - CMD-SHELL
|
||||||
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
# - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||||
interval: 10s
|
# interval: 10s
|
||||||
timeout: 5s
|
# timeout: 5s
|
||||||
retries: 30
|
# retries: 30
|
||||||
start_period: 60s
|
# start_period: 60s
|
||||||
networks:
|
# networks:
|
||||||
- openisle-network
|
# - openisle-network
|
||||||
profiles:
|
# profiles:
|
||||||
- dev
|
# - dev
|
||||||
- dev_local_backend
|
# - dev_local_backend
|
||||||
|
|
||||||
dashboards:
|
# dashboards:
|
||||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
# image: opensearchproject/opensearch-dashboards:3.0.0
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
# container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||||
environment:
|
# environment:
|
||||||
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
# OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
# DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||||
ports:
|
# ports:
|
||||||
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
# - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||||
depends_on:
|
# depends_on:
|
||||||
- opensearch
|
# - opensearch
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
networks:
|
# networks:
|
||||||
- openisle-network
|
# - openisle-network
|
||||||
profiles:
|
# profiles:
|
||||||
- dev
|
# - dev
|
||||||
- dev_local_backend
|
# - dev_local_backend
|
||||||
- prod
|
# - prod
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management
|
image: rabbitmq:3.13-management
|
||||||
@@ -200,7 +200,6 @@ services:
|
|||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
- prod
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
149
frontend_nuxt/components/TimelineReadItem.vue
Normal file
149
frontend_nuxt/components/TimelineReadItem.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-title">浏览了文章</div>
|
||||||
|
<div class="timeline-date">{{ formattedDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="article-container">
|
||||||
|
<NuxtLink :to="postLink" class="timeline-article-link">
|
||||||
|
{{ item.post?.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="timeline-snippet">
|
||||||
|
{{ strippedSnippet }}
|
||||||
|
</div>
|
||||||
|
<div class="article-meta" v-if="hasMeta">
|
||||||
|
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
|
||||||
|
<ArticleTags :tags="item.post?.tags" />
|
||||||
|
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
|
||||||
|
<comment-one class="article-comment-count-icon" />
|
||||||
|
<span>{{ item.post?.commentCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const postLink = computed(() => {
|
||||||
|
const id = props.item.post?.id
|
||||||
|
return id ? `/posts/${id}` : '#'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() =>
|
||||||
|
TimeManager.format(props.item.lastReadAt ?? props.item.createdAt),
|
||||||
|
)
|
||||||
|
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
|
||||||
|
const hasMeta = computed(() => {
|
||||||
|
const tags = props.item.post?.tags ?? []
|
||||||
|
const hasTags = Array.isArray(tags) && tags.length > 0
|
||||||
|
const hasCategory = !!props.item.post?.category
|
||||||
|
const hasCommentCount =
|
||||||
|
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
|
||||||
|
return hasTags || hasCategory || hasCommentCount
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 5px;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--timeline-card-background, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--timeline-date-color, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
color: var(--timeline-snippet-color, #666);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tag {
|
||||||
|
background-color: var(--article-info-background-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comment-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comment-count-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline-article-link {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -191,14 +191,25 @@
|
|||||||
>
|
>
|
||||||
评论和回复
|
评论和回复
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isMine"
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
|
||||||
|
@click="timelineFilter = 'reads'"
|
||||||
|
>
|
||||||
|
浏览记录
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="filteredTimelineItems.length === 0"
|
v-if="
|
||||||
text="暂无时间线"
|
timelineFilter === 'reads'
|
||||||
|
? readPosts.length === 0
|
||||||
|
: filteredTimelineItems.length === 0
|
||||||
|
"
|
||||||
|
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
|
||||||
icon="inbox"
|
icon="inbox"
|
||||||
/>
|
/>
|
||||||
<div class="timeline-list">
|
<div class="timeline-list">
|
||||||
<BaseTimeline :items="filteredTimelineItems">
|
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<template v-if="item.type === 'post'">
|
<template v-if="item.type === 'post'">
|
||||||
<TimelinePostItem :item="item" />
|
<TimelinePostItem :item="item" />
|
||||||
@@ -214,6 +225,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
|
<BaseTimeline v-else :items="readPosts">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<TimelineReadItem :item="item" />
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,6 +292,7 @@ import BaseTabs from '~/components/BaseTabs.vue'
|
|||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
||||||
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
||||||
|
import TimelineReadItem from '~/components/TimelineReadItem.vue'
|
||||||
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import UserList from '~/components/UserList.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
@@ -299,12 +316,15 @@ const hotReplies = ref([])
|
|||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const favoritePosts = ref([])
|
const favoritePosts = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
|
const readPosts = ref([])
|
||||||
const timelineFilter = ref('all')
|
const timelineFilter = ref('all')
|
||||||
const filteredTimelineItems = computed(() => {
|
const filteredTimelineItems = computed(() => {
|
||||||
if (timelineFilter.value === 'articles') {
|
if (timelineFilter.value === 'articles') {
|
||||||
return timelineItems.value.filter((item) => item.type === 'post')
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
} else if (timelineFilter.value === 'comments') {
|
} else if (timelineFilter.value === 'comments') {
|
||||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
|
} else if (timelineFilter.value === 'reads') {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return timelineItems.value
|
return timelineItems.value
|
||||||
})
|
})
|
||||||
@@ -477,6 +497,27 @@ const fetchTimeline = async () => {
|
|||||||
timelineItems.value = combineDiscussionItems(mapped)
|
timelineItems.value = combineDiscussionItems(mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchReadHistory = async () => {
|
||||||
|
if (!isMine.value) {
|
||||||
|
readPosts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
readPosts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' }))
|
||||||
|
} else {
|
||||||
|
readPosts.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchFollowUsers = async () => {
|
const fetchFollowUsers = async () => {
|
||||||
const [followerRes, followingRes] = await Promise.all([
|
const [followerRes, followingRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||||
@@ -508,6 +549,12 @@ const loadTimeline = async () => {
|
|||||||
tabLoading.value = false
|
tabLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadReadHistory = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchReadHistory()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const loadFollow = async () => {
|
const loadFollow = async () => {
|
||||||
tabLoading.value = true
|
tabLoading.value = true
|
||||||
await fetchFollowUsers()
|
await fetchFollowUsers()
|
||||||
@@ -624,8 +671,14 @@ onMounted(init)
|
|||||||
|
|
||||||
watch(selectedTab, async (val) => {
|
watch(selectedTab, async (val) => {
|
||||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
if (val === 'timeline') {
|
||||||
await loadTimeline()
|
if (timelineFilter.value === 'reads') {
|
||||||
|
if (readPosts.value.length === 0) {
|
||||||
|
await loadReadHistory()
|
||||||
|
}
|
||||||
|
} else if (timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
}
|
||||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||||
await loadFollow()
|
await loadFollow()
|
||||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||||
@@ -634,6 +687,23 @@ watch(selectedTab, async (val) => {
|
|||||||
await loadAchievements()
|
await loadAchievements()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(timelineFilter, async (val) => {
|
||||||
|
if (selectedTab.value !== 'timeline') return
|
||||||
|
if (val === 'reads') {
|
||||||
|
if (readPosts.value.length === 0) {
|
||||||
|
await loadReadHistory()
|
||||||
|
}
|
||||||
|
} else if (timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isMine, (val) => {
|
||||||
|
if (!val && timelineFilter.value === 'reads') {
|
||||||
|
timelineFilter.value = 'all'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user