Implement reaction panel with backend support

This commit is contained in:
Tim
2025-07-09 18:17:35 +08:00
parent 5554594123
commit 4627d34dbe
7 changed files with 213 additions and 51 deletions

View File

@@ -18,27 +18,14 @@
</div>
<div class="info-content-text" v-html="renderMarkdown(comment.text)"></div>
<div class="article-footer-container">
<div class="reactions-container">
<div class="reactions-viewer">
<div class="reactions-viewer-item-container">
<div class="reactions-viewer-item">🤣</div>
<div class="reactions-viewer-item"></div>
<div class="reactions-viewer-item">👏</div>
</div>
<div class="reactions-count">1882</div>
</div>
<div class="make-reaction-container">
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
<i class="far fa-comment"></i>
</div>
<div class="make-reaction-item like-reaction">
<i class="far fa-heart"></i>
</div>
<div class="make-reaction-item copy-link" @click="copyCommentLink">
<i class="fas fa-link"></i>
</div>
<div class="make-reaction-item copy-link" @click="copyCommentLink">
<i class="fas fa-link"></i>
</div>
</div>
</ReactionsGroup>
</div>
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" />
<div
@@ -81,6 +68,7 @@ import { renderMarkdown } from '../utils/markdown'
import BaseTimeline from './BaseTimeline.vue'
import { API_BASE_URL, toast } from '../main'
import { getToken } from '../utils/auth'
import ReactionsGroup from './ReactionsGroup.vue'
const CommentItem = {
name: 'CommentItem',
props: {
@@ -137,12 +125,14 @@ const CommentItem = {
time: new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
avatar: data.avatar.username,
text: data.content,
reactions: [],
reply: (data.replies || []).map(r => ({
id: r.id,
userName: r.author.username,
time: new Date(r.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
avatar: r.avatar,
text: r.content,
reactions: r.reactions || [],
reply: [],
openReplies: false
})),
@@ -168,7 +158,7 @@ const CommentItem = {
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply }
}
}
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline }
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup }
export default CommentItem
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="reactions-container">
<div class="reactions-viewer">
<div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide" @mouseleave="scheduleHide">
<template v-if="displayedReactions.length">
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">{{ iconMap[r.type] }}</div>
</template>
<div v-else class="reactions-viewer-item placeholder">点击以表态</div>
</div>
<div class="reactions-count">{{ totalCount }}</div>
</div>
<div class="make-reaction-container">
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')" :class="{ selected: userReacted('LIKE') }">
<i class="far fa-heart"></i>
<span v-if="likeCount">{{ likeCount }}</span>
</div>
<slot></slot>
</div>
<div v-if="panelVisible" class="reactions-panel" @mouseenter="cancelHide" @mouseleave="scheduleHide">
<div v-for="t in panelTypes" :key="t" class="reaction-option" @click="toggleReaction(t)" :class="{ selected: userReacted(t) }">
{{ iconMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
let cachedTypes = null
const fetchTypes = async () => {
if (cachedTypes) return cachedTypes
try {
const res = await fetch(`${API_BASE_URL}/api/reaction-types`)
if (res.ok) {
cachedTypes = await res.json()
} else {
cachedTypes = []
}
} catch {
cachedTypes = []
}
return cachedTypes
}
const iconMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡'
}
export default {
name: 'ReactionsGroup',
props: {
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const reactions = ref(props.modelValue)
watch(() => props.modelValue, v => reactions.value = v)
const reactionTypes = ref([])
onMounted(async () => {
reactionTypes.value = await fetchTypes()
})
const counts = computed(() => {
const c = {}
for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1
}
return c
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = type => reactions.value.some(r => r.type === type && r.user === authState.username)
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
})
const panelTypes = computed(() => reactionTypes.value.filter(t => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => { panelVisible.value = false }, 2000)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const url = props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type })
})
if (res.ok) {
if (res.status === 204) {
const idx = reactions.value.findIndex(r => r.type === type && r.user === authState.username)
if (idx > -1) reactions.value.splice(idx, 1)
} else {
const data = await res.json()
reactions.value.push(data)
}
emit('update:modelValue', reactions.value)
} else {
toast.error('操作失败')
}
} catch (e) {
toast.error('操作失败')
}
}
return {
iconMap,
counts,
totalCount,
likeCount,
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted
}
}
}
</script>
<style scoped>
.reactions-container { position: relative; display: flex; flex-direction: row; gap: 10px; align-items: center; width: 100%; justify-content: space-between; }
.reactions-viewer { display: flex; flex-direction: row; gap: 20px; align-items: center; }
.reactions-viewer-item-container { display: flex; flex-direction: row; gap: 2px; align-items: center; cursor: pointer; }
.reactions-viewer-item { font-size: 16px; }
.reactions-viewer-item.placeholder { opacity: 0.5; }
.reactions-count { font-size: 16px; opacity: 0.5; }
.make-reaction-container { display: flex; flex-direction: row; gap: 10px; }
.make-reaction-item { cursor: pointer; padding: 10px; border-radius: 50%; opacity: 0.5; font-size: 20px; }
.like-reaction { color: #ff0000; }
.like-reaction.selected { background-color: #ffe2e2; }
.reactions-panel { position: absolute; bottom: 30px; left: 0; background-color: var(--background-color); border: 1px solid #ccc; border-radius: 5px; padding: 5px; display: flex; flex-direction: row; gap: 5px; z-index: 10; }
.reaction-option { cursor: pointer; padding: 5px; border-radius: 4px; }
.reaction-option.selected { background-color: #e2e2e2; }
</style>

View File

@@ -27,31 +27,11 @@
<div class="info-content-text" v-html="renderMarkdown(postContent)"></div>
<div class="article-footer-container">
<div class="reactions-container">
<div class="reactions-viewer">
<div class="reactions-viewer-item-container">
<div class="reactions-viewer-item">
🤣
</div>
<div class="reactions-viewer-item">
</div>
<div class="reactions-viewer-item">
👏
</div>
</div>
<div class="reactions-count">1882</div>
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
<div class="make-reaction-item copy-link" @click="copyPostLink">
<i class="fas fa-link"></i>
</div>
<div class="make-reaction-container">
<div class="make-reaction-item like-reaction">
<i class="far fa-heart"></i>
</div>
<div class="make-reaction-item copy-link" @click="copyPostLink">
<i class="fas fa-link"></i>
</div>
</div>
</div>
</ReactionsGroup>
</div>
</div>
</div>
@@ -98,6 +78,7 @@ import CommentItem from '../components/CommentItem.vue'
import CommentEditor from '../components/CommentEditor.vue'
import BaseTimeline from '../components/BaseTimeline.vue'
import ArticleTags from '../components/ArticleTags.vue'
import ReactionsGroup from '../components/ReactionsGroup.vue'
import { renderMarkdown } from '../utils/markdown'
import { API_BASE_URL, toast } from '../main'
import { getToken } from '../utils/auth'
@@ -107,7 +88,7 @@ hatch.register()
export default {
name: 'PostPageView',
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags },
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ReactionsGroup },
setup() {
const route = useRoute()
const postId = route.params.id
@@ -118,6 +99,7 @@ export default {
const postContent = ref('')
const category = ref('')
const tags = ref([])
const postReactions = ref([])
const comments = ref([])
const isWaitingFetchingPost = ref(false);
const isWaitingPostingComment = ref(false);
@@ -150,6 +132,7 @@ export default {
time: new Date(c.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
avatar: c.author.avatar,
text: c.content,
reactions: c.reactions || [],
reply: (c.replies || []).map(mapComment),
openReplies: false,
src: c.author.avatar,
@@ -195,6 +178,7 @@ export default {
title.value = data.title
category.value = data.category
tags.value = data.tags || []
postReactions.value = data.reactions || []
comments.value = (data.comments || []).map(mapComment)
postTime.value = new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
await nextTick()
@@ -322,6 +306,8 @@ export default {
mainContainer,
currentIndex,
totalPosts,
postReactions,
postId,
postComment,
onSliderInput,
onScroll: updateCurrentIndex,

View File

@@ -28,6 +28,9 @@ public class ReactionController {
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(toDto(reaction));
}
@@ -36,6 +39,9 @@ public class ReactionController {
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(toDto(reaction));
}

View File

@@ -14,8 +14,8 @@ import lombok.Setter;
@NoArgsConstructor
@Table(name = "reactions",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "post_id"}),
@UniqueConstraint(columnNames = {"user_id", "comment_id"})
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
})
public class Reaction {
@Id

View File

@@ -13,8 +13,8 @@ import java.util.List;
import java.util.Optional;
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
Optional<Reaction> findByUserAndPost(User user, Post post);
Optional<Reaction> findByUserAndComment(User user, Comment comment);
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
List<Reaction> findByPost(Post post);
List<Reaction> findByComment(Comment comment);

View File

@@ -28,8 +28,12 @@ public class ReactionService {
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("Post not found"));
Reaction reaction = reactionRepository.findByUserAndPost(user, post)
.orElseGet(Reaction::new);
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndPostAndType(user, post, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setPost(post);
reaction.setType(type);
@@ -45,8 +49,12 @@ public class ReactionService {
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
Reaction reaction = reactionRepository.findByUserAndComment(user, comment)
.orElseGet(Reaction::new);
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndCommentAndType(user, comment, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setComment(comment);
reaction.setPost(null);