From 0edbeabac247037ece20bf04ebf94af88c67aa25 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:30:48 +0800 Subject: [PATCH] feat: allow post authors to pin comments --- .../controller/AdminCommentController.java | 29 +++++++ .../controller/CommentController.java | 12 +++ .../java/com/openisle/dto/CommentDto.java | 1 + .../com/openisle/mapper/CommentMapper.java | 1 + .../main/java/com/openisle/model/Comment.java | 3 + .../com/openisle/service/CommentService.java | 52 +++++++++++-- .../V1__add_pinned_at_to_comments.sql | 1 + frontend_nuxt/components/CommentItem.vue | 75 +++++++++++++++++-- frontend_nuxt/pages/posts/[id]/index.vue | 2 + 9 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/com/openisle/controller/AdminCommentController.java create mode 100644 backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql diff --git a/backend/src/main/java/com/openisle/controller/AdminCommentController.java b/backend/src/main/java/com/openisle/controller/AdminCommentController.java new file mode 100644 index 000000000..850b81784 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/AdminCommentController.java @@ -0,0 +1,29 @@ +package com.openisle.controller; + +import com.openisle.dto.CommentDto; +import com.openisle.mapper.CommentMapper; +import com.openisle.service.CommentService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * Endpoints for administrators to manage comments. + */ +@RestController +@RequestMapping("/api/admin/comments") +@RequiredArgsConstructor +public class AdminCommentController { + private final CommentService commentService; + private final CommentMapper commentMapper; + + @PostMapping("/{id}/pin") + public CommentDto pin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } + + @PostMapping("/{id}/unpin") + public CommentDto unpin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } +} diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index 01669ea61..09e998607 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -85,4 +85,16 @@ public class CommentController { commentService.deleteComment(auth.getName(), id); log.debug("deleteComment completed for comment {}", id); } + + @PostMapping("/comments/{id}/pin") + public CommentDto pinComment(@PathVariable Long id, Authentication auth) { + log.debug("pinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } + + @PostMapping("/comments/{id}/unpin") + public CommentDto unpinComment(@PathVariable Long id, Authentication auth) { + log.debug("unpinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } } diff --git a/backend/src/main/java/com/openisle/dto/CommentDto.java b/backend/src/main/java/com/openisle/dto/CommentDto.java index 54c3711f0..4442d35ca 100644 --- a/backend/src/main/java/com/openisle/dto/CommentDto.java +++ b/backend/src/main/java/com/openisle/dto/CommentDto.java @@ -13,6 +13,7 @@ public class CommentDto { private Long id; private String content; private LocalDateTime createdAt; + private LocalDateTime pinnedAt; private AuthorDto author; private List replies; private List reactions; diff --git a/backend/src/main/java/com/openisle/mapper/CommentMapper.java b/backend/src/main/java/com/openisle/mapper/CommentMapper.java index d9b065b45..a83fb44c3 100644 --- a/backend/src/main/java/com/openisle/mapper/CommentMapper.java +++ b/backend/src/main/java/com/openisle/mapper/CommentMapper.java @@ -24,6 +24,7 @@ public class CommentMapper { dto.setId(comment.getId()); dto.setContent(comment.getContent()); dto.setCreatedAt(comment.getCreatedAt()); + dto.setPinnedAt(comment.getPinnedAt()); dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); dto.setReward(0); return dto; diff --git a/backend/src/main/java/com/openisle/model/Comment.java b/backend/src/main/java/com/openisle/model/Comment.java index 5a1bdb5c0..d613f13da 100644 --- a/backend/src/main/java/com/openisle/model/Comment.java +++ b/backend/src/main/java/com/openisle/model/Comment.java @@ -38,4 +38,7 @@ public class Comment { @JoinColumn(name = "parent_id") private Comment parent; + @Column + private LocalDateTime pinnedAt; + } diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index 95dd7b9d7..3ac994e30 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -23,6 +23,7 @@ import java.util.List; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; @Service @RequiredArgsConstructor @@ -129,13 +130,26 @@ public class CommentService { Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); List list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); - if (sort == CommentSort.NEWEST) { - list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); - } else if (sort == CommentSort.MOST_INTERACTIONS) { - list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); + java.util.List pinned = new java.util.ArrayList<>(); + java.util.List others = new java.util.ArrayList<>(); + for (Comment c : list) { + if (c.getPinnedAt() != null) { + pinned.add(c); + } else { + others.add(c); + } } - log.debug("getCommentsForPost returning {} comments", list.size()); - return list; + pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed()); + if (sort == CommentSort.NEWEST) { + others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); + } else if (sort == CommentSort.MOST_INTERACTIONS) { + others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); + } + java.util.List result = new java.util.ArrayList<>(); + result.addAll(pinned); + result.addAll(others); + log.debug("getCommentsForPost returning {} comments", result.size()); + return result; } public List getReplies(Long parentId) { @@ -223,6 +237,32 @@ public class CommentService { log.debug("deleteCommentCascade removed comment {}", comment.getId()); } + @Transactional + public Comment pinComment(String username, Long id) { + Comment c = commentRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + c.setPinnedAt(LocalDateTime.now()); + return commentRepository.save(c); + } + + @Transactional + public Comment unpinComment(String username, Long id) { + Comment c = commentRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + c.setPinnedAt(null); + return commentRepository.save(c); + } + private int interactionCount(Comment comment) { int reactions = reactionRepository.findByComment(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); diff --git a/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql b/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql new file mode 100644 index 000000000..c5c5b6720 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__add_pinned_at_to_comments.sql @@ -0,0 +1 @@ +ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL; diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue index f22524622..07edd8bad 100644 --- a/frontend_nuxt/components/CommentItem.vue +++ b/frontend_nuxt/components/CommentItem.vue @@ -22,6 +22,7 @@ :to="`/users/${comment.userId}?tab=achievements`" >{{ getMedalTitle(comment.medal) }} + {{ comment.parentUserName }} @@ -74,6 +75,7 @@ :comment="item" :level="level + 1" :default-show-replies="item.openReplies" + :post-author-id="postAuthorId" /> @@ -119,6 +121,10 @@ const CommentItem = { type: Boolean, default: false, }, + postAuthorId: { + type: [Number, String], + required: true, + }, }, setup(props, { emit }) { const router = useRouter() @@ -171,12 +177,22 @@ const CommentItem = { }) const isAuthor = computed(() => authState.username === props.comment.userName) + const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId)) const isAdmin = computed(() => authState.role === 'ADMIN') - const commentMenuItems = computed(() => - isAuthor.value || isAdmin.value - ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] - : [], - ) + const commentMenuItems = computed(() => { + const items = [] + if (isAuthor.value || isAdmin.value) { + items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() }) + } + if (isAdmin.value || isPostAuthor.value) { + if (props.comment.pinned) { + items.push({ text: '取消置顶', onClick: () => unpinComment() }) + } else { + items.push({ text: '置顶', onClick: () => pinComment() }) + } + } + return items + }) const deleteComment = async () => { const token = getToken() if (!token) { @@ -196,6 +212,46 @@ const CommentItem = { toast.error('操作失败') } } + const pinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/pin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = true + toast.success('已置顶') + } else { + toast.error('操作失败') + } + } + const unpinComment = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const url = isAdmin.value + ? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin` + : `${API_BASE_URL}/api/comments/${props.comment.id}/unpin` + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + props.comment.pinned = false + toast.success('已取消置顶') + } else { + toast.error('操作失败') + } + } const submitReply = async (parentUserName, text, clear) => { if (!text.trim()) return isWaitingForReply.value = true @@ -284,6 +340,9 @@ const CommentItem = { isWaitingForReply, commentMenuItems, deleteComment, + pinComment, + unpinComment, + isPostAuthor, lightboxVisible, lightboxIndex, lightboxImgs, @@ -370,6 +429,12 @@ export default CommentItem margin-left: 10px; } +.pin-icon { + font-size: 12px; + margin-left: 10px; + opacity: 0.6; +} + @keyframes highlight { from { background-color: yellow; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 8e05e5d80..4b59bb9b2 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -195,6 +195,7 @@ :comment="item" :level="0" :default-show-replies="item.openReplies" + :post-author-id="author.id" @deleted="onCommentDeleted" /> @@ -405,6 +406,7 @@ export default { avatar: c.author.avatar, text: c.content, reactions: c.reactions || [], + pinned: !!c.pinnedAt, reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), openReplies: level === 0, src: c.author.avatar,