mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-25 15:40:49 +08:00
Compare commits
3 Commits
codex/refa
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edbeabac2 | ||
|
|
a3aec1133b | ||
|
|
8fa715477b |
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,4 +85,16 @@ public class CommentController {
|
|||||||
commentService.deleteComment(auth.getName(), id);
|
commentService.deleteComment(auth.getName(), id);
|
||||||
log.debug("deleteComment completed for comment {}", 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class CommentDto {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String content;
|
private String content;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
private AuthorDto author;
|
private AuthorDto author;
|
||||||
private List<CommentDto> replies;
|
private List<CommentDto> replies;
|
||||||
private List<ReactionDto> reactions;
|
private List<ReactionDto> reactions;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class CommentMapper {
|
|||||||
dto.setId(comment.getId());
|
dto.setId(comment.getId());
|
||||||
dto.setContent(comment.getContent());
|
dto.setContent(comment.getContent());
|
||||||
dto.setCreatedAt(comment.getCreatedAt());
|
dto.setCreatedAt(comment.getCreatedAt());
|
||||||
|
dto.setPinnedAt(comment.getPinnedAt());
|
||||||
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
|
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
|
||||||
dto.setReward(0);
|
dto.setReward(0);
|
||||||
return dto;
|
return dto;
|
||||||
|
|||||||
@@ -38,4 +38,7 @@ public class Comment {
|
|||||||
@JoinColumn(name = "parent_id")
|
@JoinColumn(name = "parent_id")
|
||||||
private Comment parent;
|
private Comment parent;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public enum NotificationType {
|
|||||||
REGISTER_REQUEST,
|
REGISTER_REQUEST,
|
||||||
/** A user redeemed an activity reward */
|
/** A user redeemed an activity reward */
|
||||||
ACTIVITY_REDEEM,
|
ACTIVITY_REDEEM,
|
||||||
|
/** You won a lottery post */
|
||||||
|
LOTTERY_WIN,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION
|
MENTION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.util.List;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -129,13 +130,26 @@ public class CommentService {
|
|||||||
Post post = postRepository.findById(postId)
|
Post post = postRepository.findById(postId)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
||||||
if (sort == CommentSort.NEWEST) {
|
java.util.List<Comment> pinned = new java.util.ArrayList<>();
|
||||||
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
java.util.List<Comment> others = new java.util.ArrayList<>();
|
||||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
for (Comment c : list) {
|
||||||
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
if (c.getPinnedAt() != null) {
|
||||||
|
pinned.add(c);
|
||||||
|
} else {
|
||||||
|
others.add(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.debug("getCommentsForPost returning {} comments", list.size());
|
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
|
||||||
return list;
|
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<Comment> result = new java.util.ArrayList<>();
|
||||||
|
result.addAll(pinned);
|
||||||
|
result.addAll(others);
|
||||||
|
log.debug("getCommentsForPost returning {} comments", result.size());
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Comment> getReplies(Long parentId) {
|
public List<Comment> getReplies(Long parentId) {
|
||||||
@@ -223,6 +237,32 @@ public class CommentService {
|
|||||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
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) {
|
private int interactionCount(Comment comment) {
|
||||||
int reactions = reactionRepository.findByComment(comment).size();
|
int reactions = reactionRepository.findByComment(comment).size();
|
||||||
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public class PostService {
|
|||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
private final ApplicationContext applicationContext;
|
private final ApplicationContext applicationContext;
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
||||||
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
|
private String websiteUrl;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public PostService(PostRepository postRepository,
|
public PostService(PostRepository postRepository,
|
||||||
@@ -249,6 +251,8 @@ public class PostService {
|
|||||||
if (w.getEmail() != null) {
|
if (w.getEmail() != null) {
|
||||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||||
}
|
}
|
||||||
|
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||||
|
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
:to="`/users/${comment.userId}?tab=achievements`"
|
:to="`/users/${comment.userId}?tab=achievements`"
|
||||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||||
>
|
>
|
||||||
|
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||||
<span v-if="level >= 2">
|
<span v-if="level >= 2">
|
||||||
<i class="fas fa-reply reply-icon"></i>
|
<i class="fas fa-reply reply-icon"></i>
|
||||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
:comment="item"
|
:comment="item"
|
||||||
:level="level + 1"
|
:level="level + 1"
|
||||||
:default-show-replies="item.openReplies"
|
:default-show-replies="item.openReplies"
|
||||||
|
:post-author-id="postAuthorId"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
@@ -119,6 +121,10 @@ const CommentItem = {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
postAuthorId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -171,12 +177,22 @@ const CommentItem = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||||
|
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
|
||||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
const commentMenuItems = computed(() =>
|
const commentMenuItems = computed(() => {
|
||||||
isAuthor.value || isAdmin.value
|
const items = []
|
||||||
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
|
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 deleteComment = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -196,6 +212,46 @@ const CommentItem = {
|
|||||||
toast.error('操作失败')
|
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) => {
|
const submitReply = async (parentUserName, text, clear) => {
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
isWaitingForReply.value = true
|
isWaitingForReply.value = true
|
||||||
@@ -284,6 +340,9 @@ const CommentItem = {
|
|||||||
isWaitingForReply,
|
isWaitingForReply,
|
||||||
commentMenuItems,
|
commentMenuItems,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
pinComment,
|
||||||
|
unpinComment,
|
||||||
|
isPostAuthor,
|
||||||
lightboxVisible,
|
lightboxVisible,
|
||||||
lightboxIndex,
|
lightboxIndex,
|
||||||
lightboxImgs,
|
lightboxImgs,
|
||||||
@@ -370,6 +429,12 @@ export default CommentItem
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes highlight {
|
@keyframes highlight {
|
||||||
from {
|
from {
|
||||||
background-color: yellow;
|
background-color: yellow;
|
||||||
|
|||||||
@@ -185,6 +185,19 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_WIN'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
恭喜你在抽奖贴
|
||||||
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
中获奖
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -565,6 +578,7 @@ export default {
|
|||||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||||
|
LOTTERY_WIN: 'fas fa-trophy',
|
||||||
MENTION: 'fas fa-at',
|
MENTION: 'fas fa-at',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +636,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (n.type === 'LOTTERY_WIN') {
|
||||||
|
notifications.value.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (n.type === 'POST_UPDATED') {
|
} else if (n.type === 'POST_UPDATED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -791,6 +816,8 @@ export default {
|
|||||||
return '有人申请注册'
|
return '有人申请注册'
|
||||||
case 'ACTIVITY_REDEEM':
|
case 'ACTIVITY_REDEEM':
|
||||||
return '有人申请兑换奶茶'
|
return '有人申请兑换奶茶'
|
||||||
|
case 'LOTTERY_WIN':
|
||||||
|
return '抽奖中奖了'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
:comment="item"
|
:comment="item"
|
||||||
:level="0"
|
:level="0"
|
||||||
:default-show-replies="item.openReplies"
|
:default-show-replies="item.openReplies"
|
||||||
|
:post-author-id="author.id"
|
||||||
@deleted="onCommentDeleted"
|
@deleted="onCommentDeleted"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -405,6 +406,7 @@ export default {
|
|||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
reactions: c.reactions || [],
|
||||||
|
pinned: !!c.pinnedAt,
|
||||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||||
openReplies: level === 0,
|
openReplies: level === 0,
|
||||||
src: c.author.avatar,
|
src: c.author.avatar,
|
||||||
|
|||||||
Reference in New Issue
Block a user