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/assets/global.css b/frontend_nuxt/assets/global.css index d0967523c..6b29dcabd 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -90,7 +90,8 @@ body { } .vditor-toolbar--pin { - top: var(--header-height) !important; + top: calc(var(--header-height) + 1px) !important; + z-index: 2000; } .vditor-panel { diff --git a/frontend_nuxt/components/CommentEditor.vue b/frontend_nuxt/components/CommentEditor.vue index 0914d3ef1..422fb563c 100644 --- a/frontend_nuxt/components/CommentEditor.vue +++ b/frontend_nuxt/components/CommentEditor.vue @@ -22,6 +22,7 @@ import { getEditorTheme as getEditorThemeUtil, getPreviewTheme as getPreviewThemeUtil, } from '~/utils/vditor' +import '~/assets/global.css' import LoginOverlay from '~/components/LoginOverlay.vue' export default { 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/components/ConfirmDialog.vue b/frontend_nuxt/components/ConfirmDialog.vue index 922dd402a..f3a678d8b 100644 --- a/frontend_nuxt/components/ConfirmDialog.vue +++ b/frontend_nuxt/components/ConfirmDialog.vue @@ -1,21 +1,21 @@ - \ No newline at end of file +.cancel-button:hover { + opacity: 0.85; +} + diff --git a/frontend_nuxt/components/PostEditor.vue b/frontend_nuxt/components/PostEditor.vue index 06994e041..0c9a728f7 100644 --- a/frontend_nuxt/components/PostEditor.vue +++ b/frontend_nuxt/components/PostEditor.vue @@ -16,6 +16,7 @@ import { getEditorTheme as getEditorThemeUtil, getPreviewTheme as getPreviewThemeUtil, } from '~/utils/vditor' +import '~/assets/global.css' export default { name: 'PostEditor', diff --git a/frontend_nuxt/components/ReactionsGroup.vue b/frontend_nuxt/components/ReactionsGroup.vue index d73861bf2..8465270ff 100644 --- a/frontend_nuxt/components/ReactionsGroup.vue +++ b/frontend_nuxt/components/ReactionsGroup.vue @@ -51,7 +51,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { toast } from '~/main' import { authState, getToken } from '~/utils/auth' import { reactionEmojiMap } from '~/utils/reactions' -import { useReactionTypes } from '~/composables/useReactionTypes' +import { useReactionTypes } from '~/composables/useReactionTypes' const { reactionTypes, initialize } = useReactionTypes() @@ -237,7 +237,7 @@ onMounted(async () => { .make-reaction-item { cursor: pointer; - padding: 10px; + padding: 4px; opacity: 0.5; border-radius: 8px; font-size: 20px; diff --git a/frontend_nuxt/composables/useConfirm.js b/frontend_nuxt/composables/useConfirm.js deleted file mode 100644 index feffa0712..000000000 --- a/frontend_nuxt/composables/useConfirm.js +++ /dev/null @@ -1,42 +0,0 @@ -import { ref } from 'vue' - -const state = ref({ - visible: false, - title: '', - message: '', - resolve: null, - reject: null, -}) - -export const useConfirm = () => { - const confirm = (title, message) => { - state.value.title = title - state.value.message = message - state.value.visible = true - return new Promise((resolve, reject) => { - state.value.resolve = resolve - state.value.reject = reject - }) - } - - const onConfirm = () => { - if (state.value.resolve) { - state.value.resolve(true) - } - state.value.visible = false - } - - const onCancel = () => { - if (state.value.reject) { - state.value.reject(false) - } - state.value.visible = false - } - - return { - confirm, - onConfirm, - onCancel, - state, - } -} \ No newline at end of file diff --git a/frontend_nuxt/composables/useConfirm.ts b/frontend_nuxt/composables/useConfirm.ts new file mode 100644 index 000000000..d33822a0e --- /dev/null +++ b/frontend_nuxt/composables/useConfirm.ts @@ -0,0 +1,52 @@ +// composables/useConfirm.ts +import { ref } from 'vue' + +// 全局单例(SPA 下即可;Nuxt/SSR 下见文末“SSR 提醒”) +const visible = ref(false) +const title = ref('') +const message = ref('') + +let resolver: ((ok: boolean) => void) | null = null + +function reset() { + visible.value = false + title.value = '' + message.value = '' + resolver = null +} + +export function useConfirm() { + /** + * 打开确认框,返回 Promise + * - 确认 => resolve(true) + * - 取消/关闭 => resolve(false) + * 若并发调用,以最后一次为准(更简单直观) + */ + const confirm = (t: string, m: string) => { + title.value = t + message.value = m + visible.value = true + return new Promise((resolve) => { + resolver = resolve + }) + } + + const onConfirm = () => { + resolver?.(true) + reset() + } + + const onCancel = () => { + resolver?.(false) + reset() + } + + return { + visible, + title, + message, + confirm, + onConfirm, + onCancel, + } +} diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 5771163d9..d62b95792 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -15,13 +15,22 @@
审核中
已拒绝
-
+
已关闭
+
{{ isMobile ? '订阅' : '订阅文章' }}
-
+
{{ isMobile ? '退订' : '取消订阅' }} @@ -44,8 +53,12 @@
{{ author.username }} - {{ - getMedalTitle(author.displayMedal) }} + {{ getMedalTitle(author.displayMedal) }}
{{ postTime }}
@@ -56,12 +69,20 @@
{{ author.username }} - {{ - getMedalTitle(author.displayMedal) }} + {{ getMedalTitle(author.displayMedal) }}
{{ postTime }}
-
+