mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-31 06:57:35 +08:00
Merge branch 'main' into codex/post
This commit is contained in:
@@ -44,7 +44,7 @@ export default {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: white;
|
color: var(--text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -18,27 +18,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-content-text" v-html="renderMarkdown(comment.text)"></div>
|
<div class="info-content-text" v-html="renderMarkdown(comment.text)"></div>
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<div class="reactions-container">
|
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||||
<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">
|
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||||
<i class="far fa-comment"></i>
|
<i class="far fa-comment"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-item like-reaction">
|
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||||
<i class="far fa-heart"></i>
|
<i class="fas fa-link"></i>
|
||||||
</div>
|
|
||||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
|
||||||
<i class="fas fa-link"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ReactionsGroup>
|
||||||
</div>
|
</div>
|
||||||
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" />
|
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" />
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +69,7 @@ import { renderMarkdown } from '../utils/markdown'
|
|||||||
import BaseTimeline from './BaseTimeline.vue'
|
import BaseTimeline from './BaseTimeline.vue'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '../main'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken } from '../utils/auth'
|
||||||
|
import ReactionsGroup from './ReactionsGroup.vue'
|
||||||
const CommentItem = {
|
const CommentItem = {
|
||||||
name: 'CommentItem',
|
name: 'CommentItem',
|
||||||
props: {
|
props: {
|
||||||
@@ -139,12 +127,14 @@ const CommentItem = {
|
|||||||
time: new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
time: new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
||||||
avatar: data.author.avatar,
|
avatar: data.author.avatar,
|
||||||
text: data.content,
|
text: data.content,
|
||||||
|
reactions: [],
|
||||||
reply: (data.replies || []).map(r => ({
|
reply: (data.replies || []).map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
userName: r.author.username,
|
userName: r.author.username,
|
||||||
time: new Date(r.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
time: new Date(r.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
||||||
avatar: r.author.avatar,
|
avatar: r.author.avatar,
|
||||||
text: r.content,
|
text: r.content,
|
||||||
|
reactions: r.reactions || [],
|
||||||
reply: [],
|
reply: [],
|
||||||
openReplies: false,
|
openReplies: false,
|
||||||
src: r.author.avatar,
|
src: r.author.avatar,
|
||||||
@@ -174,7 +164,7 @@ const CommentItem = {
|
|||||||
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply }
|
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline }
|
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup }
|
||||||
export default CommentItem
|
export default CommentItem
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
175
open-isle-cli/src/components/ReactionsGroup.vue
Normal file
175
open-isle-cli/src/components/ReactionsGroup.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<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 token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
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>
|
||||||
@@ -15,3 +15,21 @@ export async function fetchUnreadCount() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markNotificationsRead(ids) {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token || !ids || ids.length === 0) return false
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids })
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
|
|
||||||
<div class="topic-container">
|
<div class="topic-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div v-for="topic in topics" :key="topic" class="topic-item" :class="{ selected: topic === selectedTopic }">
|
<div
|
||||||
|
v-for="topic in topics"
|
||||||
|
:key="topic"
|
||||||
|
class="topic-item"
|
||||||
|
:class="{ selected: topic === selectedTopic }"
|
||||||
|
@click="selectedTopic = topic"
|
||||||
|
>
|
||||||
{{ topic }}
|
{{ topic }}
|
||||||
</div>
|
</div>
|
||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
@@ -18,60 +24,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-container">
|
<div class="article-container">
|
||||||
<div class="header-container">
|
<template v-if="selectedTopic === '最新'">
|
||||||
<div class="header-item main-item">
|
<div class="header-container">
|
||||||
<div class="header-item-text">话题</div>
|
<div class="header-item main-item">
|
||||||
</div>
|
<div class="header-item-text">话题</div>
|
||||||
<div class="header-item avatars">
|
</div>
|
||||||
<div class="header-item-text">参与人员</div>
|
<div class="header-item avatars">
|
||||||
</div>
|
<div class="header-item-text">参与人员</div>
|
||||||
<div class="header-item">
|
</div>
|
||||||
<div class="header-item-text">回复</div>
|
<div class="header-item">
|
||||||
</div>
|
<div class="header-item-text">回复</div>
|
||||||
<div class="header-item">
|
</div>
|
||||||
<div class="header-item-text">浏览</div>
|
<div class="header-item">
|
||||||
</div>
|
<div class="header-item-text">浏览</div>
|
||||||
<div class="header-item">
|
</div>
|
||||||
<div class="header-item-text">活动</div>
|
<div class="header-item">
|
||||||
</div>
|
<div class="header-item-text">活动</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="articles.length === 0">
|
|
||||||
<div class="no-posts-container">
|
|
||||||
<div class="no-posts-text">暂时没有帖子 :( 点击发帖发送第一篇相关帖子吧!</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
|
||||||
<div class="article-main-container">
|
|
||||||
<router-link class="article-item-title main-item" :to="`/posts/${article.id}`">
|
|
||||||
{{ article.title }}
|
|
||||||
</router-link>
|
|
||||||
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
|
|
||||||
<div class="article-info-container main-item">
|
|
||||||
<ArticleTags :tags="[article.category]" />
|
|
||||||
<ArticleTags :tags="article.tags" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-member-avatars-container">
|
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
||||||
<div class="article-member-avatar-item" v-for="(avatar, idx) in article.members" :key="idx">
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
<img class="article-member-avatar-item-img" :src="avatar" alt="avatar">
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="articles.length === 0">
|
||||||
|
<div class="no-posts-container">
|
||||||
|
<div class="no-posts-text">暂时没有帖子 :( 点击发帖发送第一篇相关帖子吧!</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="article-comments">
|
|
||||||
{{ article.comments }}
|
<div class="article-item" v-for="article in articles" :key="article.id">
|
||||||
</div>
|
<div class="article-main-container">
|
||||||
<div class="article-views">
|
<router-link class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||||
{{ article.views }}
|
{{ article.title }}
|
||||||
</div>
|
</router-link>
|
||||||
<div class="article-time">
|
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
|
||||||
{{ article.time }}
|
<div class="article-info-container main-item">
|
||||||
|
<ArticleTags :tags="[article.category]" />
|
||||||
|
<ArticleTags :tags="article.tags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-member-avatars-container">
|
||||||
|
<div class="article-member-avatar-item" v-for="(avatar, idx) in article.members" :key="idx">
|
||||||
|
<img class="article-member-avatar-item-img" :src="avatar" alt="avatar">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="article-comments">
|
||||||
|
{{ article.comments }}
|
||||||
|
</div>
|
||||||
|
<div class="article-views">
|
||||||
|
{{ article.views }}
|
||||||
|
</div>
|
||||||
|
<div class="article-time">
|
||||||
|
{{ article.time }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="selectedTopic === '排行榜'" class="placeholder-container">
|
||||||
|
排行榜功能开发中,敬请期待。
|
||||||
|
</div>
|
||||||
|
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
|
||||||
|
热门帖子功能开发中,敬请期待。
|
||||||
|
</div>
|
||||||
|
<div v-else class="placeholder-container">
|
||||||
|
分类浏览功能开发中,敬请期待。
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
@@ -350,4 +367,13 @@ export default {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.placeholder-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,18 +13,19 @@
|
|||||||
|
|
||||||
<BaseTimeline :items="notifications">
|
<BaseTimeline :items="notifications">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<div class="notif-content">
|
<div class="notif-content" :class="{ read: item.read }">
|
||||||
|
<span v-if="!item.read" class="unread-dot"></span>
|
||||||
<span class="notif-type">
|
<span class="notif-type">
|
||||||
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
||||||
<div class="notif-content-container">
|
<div class="notif-content-container">
|
||||||
<span class="notif-user">{{ item.comment.author.username }} </span> 对我的评论
|
<span class="notif-user">{{ item.comment.author.username }} </span> 对我的评论
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text"
|
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
||||||
{{ sanitizeDescription(item.parentComment.content) }}
|
{{ sanitizeDescription(item.parentComment.content) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span> 回复了 <span>
|
</span> 回复了 <span>
|
||||||
<router-link class="notif-content-text" :to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
{{ sanitizeDescription(item.comment.content) }}
|
{{ sanitizeDescription(item.comment.content) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -34,11 +35,11 @@
|
|||||||
<div class="notif-content-container">
|
<div class="notif-content-container">
|
||||||
<span class="notif-user">{{ item.comment.author.username }} </span> 对我的文章
|
<span class="notif-user">{{ item.comment.author.username }} </span> 对我的文章
|
||||||
<span>
|
<span>
|
||||||
<router-link class="notif-content-text" :to="`/posts/${item.post.id}`">
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||||
{{ sanitizeDescription(item.post.title) }}
|
{{ sanitizeDescription(item.post.title) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span> 回复了 <span>
|
</span> 回复了 <span>
|
||||||
<router-link class="notif-content-text" :to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||||
{{ sanitizeDescription(item.comment.content) }}
|
{{ sanitizeDescription(item.comment.content) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</span>
|
</span>
|
||||||
@@ -63,6 +64,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { API_BASE_URL } from '../main'
|
import { API_BASE_URL } from '../main'
|
||||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken } from '../utils/auth'
|
||||||
|
import { markNotificationsRead } from '../utils/notification'
|
||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { stripMarkdown } from '../utils/markdown'
|
import { stripMarkdown } from '../utils/markdown'
|
||||||
import { hatch } from 'ldrs'
|
import { hatch } from 'ldrs'
|
||||||
@@ -76,6 +78,15 @@ export default {
|
|||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const isLoadingMessage = ref(false)
|
const isLoadingMessage = ref(false)
|
||||||
|
|
||||||
|
const markRead = async id => {
|
||||||
|
if (!id) return
|
||||||
|
const ok = await markNotificationsRead([id])
|
||||||
|
if (ok) {
|
||||||
|
const n = notifications.value.find(n => n.id === id)
|
||||||
|
if (n) n.read = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
POST_VIEWED: 'fas fa-eye',
|
POST_VIEWED: 'fas fa-eye',
|
||||||
COMMENT_REPLY: 'fas fa-reply',
|
COMMENT_REPLY: 'fas fa-reply',
|
||||||
@@ -114,7 +125,10 @@ export default {
|
|||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
src: n.comment.author.avatar,
|
src: n.comment.author.avatar,
|
||||||
iconClick: () => router.push(`/users/${n.comment.author.id}`)
|
iconClick: () => {
|
||||||
|
markRead(n.id)
|
||||||
|
router.push(`/users/${n.comment.author.id}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
@@ -149,7 +163,7 @@ export default {
|
|||||||
|
|
||||||
onMounted(fetchNotifications)
|
onMounted(fetchNotifications)
|
||||||
|
|
||||||
return { notifications, formatType, sanitizeDescription, isLoadingMessage }
|
return { notifications, formatType, sanitizeDescription, isLoadingMessage, markRead }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -187,6 +201,21 @@ export default {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content.read {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-type {
|
.notif-type {
|
||||||
|
|||||||
@@ -27,31 +27,11 @@
|
|||||||
<div class="info-content-text" v-html="renderMarkdown(postContent)"></div>
|
<div class="info-content-text" v-html="renderMarkdown(postContent)"></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<div class="reactions-container">
|
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
||||||
<div class="reactions-viewer">
|
<div class="make-reaction-item copy-link" @click="copyPostLink">
|
||||||
<div class="reactions-viewer-item-container">
|
<i class="fas fa-link"></i>
|
||||||
<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>
|
||||||
|
</ReactionsGroup>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +78,7 @@ import CommentItem from '../components/CommentItem.vue'
|
|||||||
import CommentEditor from '../components/CommentEditor.vue'
|
import CommentEditor from '../components/CommentEditor.vue'
|
||||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||||
import ArticleTags from '../components/ArticleTags.vue'
|
import ArticleTags from '../components/ArticleTags.vue'
|
||||||
|
import ReactionsGroup from '../components/ReactionsGroup.vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '../main'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken } from '../utils/auth'
|
||||||
@@ -107,7 +88,7 @@ hatch.register()
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PostPageView',
|
name: 'PostPageView',
|
||||||
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags },
|
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ReactionsGroup },
|
||||||
setup() {
|
setup() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const postId = route.params.id
|
const postId = route.params.id
|
||||||
@@ -118,6 +99,7 @@ export default {
|
|||||||
const postContent = ref('')
|
const postContent = ref('')
|
||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
|
const postReactions = ref([])
|
||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const isWaitingFetchingPost = ref(false);
|
const isWaitingFetchingPost = ref(false);
|
||||||
const isWaitingPostingComment = ref(false);
|
const isWaitingPostingComment = ref(false);
|
||||||
@@ -150,6 +132,7 @@ export default {
|
|||||||
time: new Date(c.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
time: new Date(c.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }),
|
||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
|
reactions: c.reactions || [],
|
||||||
reply: (c.replies || []).map(mapComment),
|
reply: (c.replies || []).map(mapComment),
|
||||||
openReplies: false,
|
openReplies: false,
|
||||||
src: c.author.avatar,
|
src: c.author.avatar,
|
||||||
@@ -196,6 +179,7 @@ export default {
|
|||||||
title.value = data.title
|
title.value = data.title
|
||||||
category.value = data.category
|
category.value = data.category
|
||||||
tags.value = data.tags || []
|
tags.value = data.tags || []
|
||||||
|
postReactions.value = data.reactions || []
|
||||||
comments.value = (data.comments || []).map(mapComment)
|
comments.value = (data.comments || []).map(mapComment)
|
||||||
postTime.value = new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
postTime.value = new Date(data.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -323,6 +307,8 @@ export default {
|
|||||||
mainContainer,
|
mainContainer,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
totalPosts,
|
totalPosts,
|
||||||
|
postReactions,
|
||||||
|
postId,
|
||||||
postComment,
|
postComment,
|
||||||
onSliderInput,
|
onSliderInput,
|
||||||
onScroll: updateCurrentIndex,
|
onScroll: updateCurrentIndex,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public class ReactionController {
|
|||||||
@RequestBody ReactionRequest req,
|
@RequestBody ReactionRequest req,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||||
|
if (reaction == null) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
return ResponseEntity.ok(toDto(reaction));
|
return ResponseEntity.ok(toDto(reaction));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,9 @@ public class ReactionController {
|
|||||||
@RequestBody ReactionRequest req,
|
@RequestBody ReactionRequest req,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||||
|
if (reaction == null) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
return ResponseEntity.ok(toDto(reaction));
|
return ResponseEntity.ok(toDto(reaction));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import lombok.Setter;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "reactions",
|
@Table(name = "reactions",
|
||||||
uniqueConstraints = {
|
uniqueConstraints = {
|
||||||
@UniqueConstraint(columnNames = {"user_id", "post_id"}),
|
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
|
||||||
@UniqueConstraint(columnNames = {"user_id", "comment_id"})
|
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
|
||||||
})
|
})
|
||||||
public class Reaction {
|
public class Reaction {
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
|
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
|
||||||
Optional<Reaction> findByUserAndPost(User user, Post post);
|
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
|
||||||
Optional<Reaction> findByUserAndComment(User user, Comment comment);
|
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
|
||||||
List<Reaction> findByPost(Post post);
|
List<Reaction> findByPost(Post post);
|
||||||
List<Reaction> findByComment(Comment comment);
|
List<Reaction> findByComment(Comment comment);
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ public class ReactionService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
Post post = postRepository.findById(postId)
|
Post post = postRepository.findById(postId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Post not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Post not found"));
|
||||||
Reaction reaction = reactionRepository.findByUserAndPost(user, post)
|
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndPostAndType(user, post, type);
|
||||||
.orElseGet(Reaction::new);
|
if (existing.isPresent()) {
|
||||||
|
reactionRepository.delete(existing.get());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Reaction reaction = new Reaction();
|
||||||
reaction.setUser(user);
|
reaction.setUser(user);
|
||||||
reaction.setPost(post);
|
reaction.setPost(post);
|
||||||
reaction.setType(type);
|
reaction.setType(type);
|
||||||
@@ -45,8 +49,12 @@ public class ReactionService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
Comment comment = commentRepository.findById(commentId)
|
Comment comment = commentRepository.findById(commentId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||||
Reaction reaction = reactionRepository.findByUserAndComment(user, comment)
|
java.util.Optional<Reaction> existing = reactionRepository.findByUserAndCommentAndType(user, comment, type);
|
||||||
.orElseGet(Reaction::new);
|
if (existing.isPresent()) {
|
||||||
|
reactionRepository.delete(existing.get());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Reaction reaction = new Reaction();
|
||||||
reaction.setUser(user);
|
reaction.setUser(user);
|
||||||
reaction.setComment(comment);
|
reaction.setComment(comment);
|
||||||
reaction.setPost(null);
|
reaction.setPost(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user