diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 736cba857..3dd4662e0 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -62,6 +62,16 @@ public class PostController { postService.deletePost(id, auth.getName()); } + @PostMapping("/{id}/close") + public PostSummaryDto close(@PathVariable Long id, Authentication auth) { + return postMapper.toSummaryDto(postService.closePost(id, auth.getName())); + } + + @PostMapping("/{id}/reopen") + public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) { + return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName())); + } + @GetMapping("/{id}") public ResponseEntity getPost(@PathVariable Long id, Authentication auth) { String viewer = auth != null ? auth.getName() : null; diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index e47a03e11..665c590f7 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -32,5 +32,6 @@ public class PostSummaryDto { private PostType type; private LotteryDto lottery; private boolean rssExcluded; + private boolean closed; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 0ef432228..d36b25e32 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -64,6 +64,7 @@ public class PostMapper { dto.setStatus(post.getStatus()); dto.setPinnedAt(post.getPinnedAt()); dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded()); + dto.setClosed(post.isClosed()); List reactions = reactionService.getReactionsForPost(post.getId()) .stream() diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index d314ca9a0..ccd614574 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -64,6 +64,9 @@ public class Post { @Column(nullable = false) private PostType type = PostType.NORMAL; + @Column(nullable = false) + private boolean closed = false; + @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 3ac994e30..8121b80d9 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -52,6 +52,9 @@ public class CommentService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Post post = postRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + if (post.isClosed()) { + throw new IllegalStateException("Post closed"); + } Comment comment = new Comment(); comment.setAuthor(author); comment.setPost(post); @@ -94,6 +97,9 @@ public class CommentService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Comment parent = commentRepository.findById(parentId) .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + if (parent.getPost().isClosed()) { + throw new IllegalStateException("Post closed"); + } Comment comment = new Comment(); comment.setAuthor(author); comment.setPost(parent.getPost()); diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 2e1106e04..e9a205fb9 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -512,6 +512,30 @@ public class PostService { return postRepository.save(post); } + public Post closePost(Long id, String username) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + post.setClosed(true); + return postRepository.save(post); + } + + public Post reopenPost(Long id, String username) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + post.setClosed(false); + return postRepository.save(post); + } + @org.springframework.transaction.annotation.Transactional public Post updatePost(Long id, String username, diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue index 2ea71e0d0..10f74bf4e 100644 --- a/frontend_nuxt/components/CommentItem.vue +++ b/frontend_nuxt/components/CommentItem.vue @@ -57,7 +57,7 @@ v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" - :disabled="!loggedIn" + :disabled="!loggedIn || postClosed" :show-login-overlay="!loggedIn" :parent-user-name="comment.userName" /> @@ -76,6 +76,7 @@ :level="level + 1" :default-show-replies="item.openReplies" :post-author-id="postAuthorId" + :post-closed="postClosed" /> @@ -122,6 +123,10 @@ const props = defineProps({ type: [Number, String], required: true, }, + postClosed: { + type: Boolean, + default: false, + }, }) const emit = defineEmits(['deleted']) @@ -148,6 +153,7 @@ const toggleReplies = () => { } const toggleEditor = () => { + if (props.postClosed) return showEditor.value = !showEditor.value if (showEditor.value) { setTimeout(() => { @@ -213,6 +219,10 @@ const deleteComment = async () => { } const submitReply = async (parentUserName, text, clear) => { if (!text.trim()) return + if (props.postClosed) { + toast.error('帖子已关闭') + return + } isWaitingForReply.value = true const token = getToken() if (!token) { diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index d2e76abc1..64d090aac 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -15,6 +15,7 @@
审核中
已拒绝
+
已关闭
@@ -196,6 +197,7 @@ :level="0" :default-show-replies="item.openReplies" :post-author-id="author.id" + :post-closed="closed" @deleted="onCommentDeleted" /> @@ -278,6 +280,7 @@ const tags = ref([]) const postReactions = ref([]) const comments = ref([]) const status = ref('PUBLISHED') +const closed = ref(false) const pinnedAt = ref(null) const rssExcluded = ref(false) const isWaitingPostingComment = ref(false) @@ -361,6 +364,11 @@ const articleMenuItems = computed(() => { if (isAuthor.value || isAdmin.value) { items.push({ text: '编辑文章', onClick: () => editPost() }) items.push({ text: '删除文章', color: 'red', onClick: deletePost }) + if (closed.value) { + items.push({ text: '重新打开帖子', onClick: () => reopenPost() }) + } else { + items.push({ text: '关闭帖子', onClick: () => closePost() }) + } } if (isAdmin.value) { if (pinnedAt.value) { @@ -496,6 +504,7 @@ watchEffect(() => { postReactions.value = data.reactions || [] subscribed.value = !!data.subscribed status.value = data.status + closed.value = data.closed pinnedAt.value = data.pinnedAt rssExcluded.value = data.rssExcluded postTime.value = TimeManager.format(data.createdAt) @@ -555,6 +564,10 @@ const onSliderInput = (e) => { const postComment = async (parentUserName, text, clear) => { if (!text.trim()) return + if (closed.value) { + toast.error('帖子已关闭') + return + } console.debug('Posting comment', { postId, text }) isWaitingPostingComment.value = true const token = getToken() @@ -693,6 +706,38 @@ const includeRss = async () => { } } +const closePost = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/close`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + closed.value = true + toast.success('已关闭') + await refreshPost() + } else { + toast.error('操作失败') + } +} + +const reopenPost = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/reopen`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + closed.value = false + toast.success('已重新打开') + await refreshPost() + } else { + toast.error('操作失败') + } +} + const editPost = () => { navigateTo(`/posts/${postId}/edit`, { replace: true }) } @@ -1050,6 +1095,15 @@ onMounted(async () => { font-size: 14px; } +.article-closed-button { + background-color: var(--background-color); + color: gray; + border: 1px solid gray; + padding: 5px 10px; + border-radius: 8px; + font-size: 14px; +} + .article-title { font-size: 30px; font-weight: bold;