Commit 8e4c1e13 by lijiabin

【需求 21254】 feat: 评论完成置顶相关的功能(只有作者有权置顶)

parent 981a41e2
...@@ -369,3 +369,13 @@ export const getSpecificVideoWatchReward = (pageKey: SpecificVideoRewardEnum) => ...@@ -369,3 +369,13 @@ export const getSpecificVideoWatchReward = (pageKey: SpecificVideoRewardEnum) =>
}, },
}) })
} }
/**
* 置顶 取消置顶评论
*/
export const topOrCancelTopComment = (commentId: number) => {
return service.request<boolean>({
url: `/api/cultureComment/topComment?commentId=${commentId}`,
method: 'POST',
})
}
<template> <template>
<div <div ref="commentRef" class="bg-white rounded-3 shadow-sm border border-gray-100 overflow-hidden">
ref="commentRef"
class="bg-white rounded-lg shadow-sm border border-white/50 overflow-hidden"
>
<!-- 评论筛选 --> <!-- 评论筛选 -->
<div class="p-4 border-b border-gray-100"> <div class="p-4 border-b border-gray-100">
<div class="flex items-center gap-4 justify-between"> <div class="flex items-center gap-4 justify-between">
...@@ -58,12 +55,12 @@ ...@@ -58,12 +55,12 @@
<div> <div>
<!-- 发表评论 --> <!-- 发表评论 -->
<div class="p-4 border-b border-gray-100"> <div class="px-5 py-4 border-b border-gray-100">
<div class="flex gap-3"> <div class="flex gap-3">
<img <img
:src="userAvatar" :src="userAvatar"
alt="" alt=""
class="w-10 h-10 rounded-full object-cover cursor-pointer" class="w-10 h-10 rounded-full object-cover cursor-pointer ring-2 ring-slate-100"
@click="jumpToUserHomePage({ userId: userInfo.userId, type })" @click="jumpToUserHomePage({ userId: userInfo.userId, type })"
/> />
<CommentBox <CommentBox
...@@ -98,19 +95,50 @@ ...@@ -98,19 +95,50 @@
:key="item.id" :key="item.id"
:ref="(el) => (commentItemRefList[index] = el as HTMLElement)" :ref="(el) => (commentItemRefList[index] = el as HTMLElement)"
> >
<div class="p-4 transition-colors"> <div
class="px-5 py-4"
:class="{ 'top-comment-highlight': highlightCommentId === item.id }"
>
<div class="flex gap-3"> <div class="flex gap-3">
<img <img
@click="jumpToUserHomePage({ userId: item.userId, type })" @click="jumpToUserHomePage({ userId: item.userId, type })"
:src="isReal ? item.avatar : item.hiddenAvatar" :src="isReal ? item.avatar : item.hiddenAvatar"
alt="" alt=""
class="w-10 h-10 rounded-full object-cover cursor-pointer" class="w-10 h-10 rounded-full object-cover cursor-pointer ring-2 ring-slate-100"
/> />
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-800">{{ <span class="font-semibold text-gray-800">{{
isReal ? item.replyUser : item.hiddenName isReal ? item.replyUser : item.hiddenName
}}</span> }}</span>
<span
v-if="item.isTop === BooleanFlag.YES"
class="inline-flex items-center gap-1 px-2 py-0.5 text-13px leading-4 font-medium text-amber-700 bg-amber-50/80 border border-amber-200/70 rounded-full"
>
<span
class="top-badge-dot w-1.5 h-1.5 rounded-full bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,0.18)]"
></span>
置顶评论
</span>
</div>
<!-- 作者有权利置顶 并且不是问吧(问吧是获取的二级评论列表) -->
<button
v-if="isAuthor && !isQuestion"
type="button"
class="group cursor-pointer inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-all duration-200 active:scale-98 disabled:opacity-55 disabled:cursor-not-allowed"
:class="
item.isTop === BooleanFlag.YES
? 'text-amber-700 border-amber-300/80 bg-amber-50 hover:bg-amber-100/80'
: 'text-slate-600 border-slate-200 bg-white hover:text-indigo-600 hover:border-indigo-200 hover:bg-indigo-50/70'
"
:disabled="topCommentPendingId !== null"
@click="handleTopComment(item)"
>
<span class="tracking-[0.2px]">{{
item.isTop === BooleanFlag.YES ? '取消置顶' : '置顶评论'
}}</span>
</button>
<!-- <span <!-- <span
class="px-2 py-0.5 text-xs bg-gradient-to-r from-purple-100 to-blue-100 text-purple-600 rounded-full" class="px-2 py-0.5 text-xs bg-gradient-to-r from-purple-100 to-blue-100 text-purple-600 rounded-full"
> >
...@@ -140,8 +168,8 @@ ...@@ -140,8 +168,8 @@
/> />
</div> </div>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center">
<div class="flex items-center gap-6 text-gray-500"> <div class="flex items-center gap-5 text-gray-500">
<span class="flex items-center gap-2 text-[14px]"> <span class="flex items-center gap-2 text-[14px]">
<span <span
>{{ dayjs(item.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }} >{{ dayjs(item.createTime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
...@@ -149,7 +177,7 @@ ...@@ -149,7 +177,7 @@
<span>·</span> <span>·</span>
<span class="text-gray-500">{{ item.region }}</span> <span class="text-gray-500">{{ item.region }}</span>
</span> </span>
<div class="flex gap-2 items-center hover:text-blue-500"> <div class="flex gap-2 items-center hover:text-blue-500 transition-colors">
<div <div
class="flex items-center gap-1 cursor-pointer" class="flex items-center gap-1 cursor-pointer"
@click="handleLickComment(item)" @click="handleLickComment(item)"
...@@ -183,7 +211,7 @@ ...@@ -183,7 +211,7 @@
@click="jumpToUserHomePage({ userId: child.userId, type })" @click="jumpToUserHomePage({ userId: child.userId, type })"
:src="child.avatar" :src="child.avatar"
alt="" alt=""
class="w-8 h-8 rounded-full object-cover cursor-pointer" class="w-8 h-8 rounded-full object-cover cursor-pointer ring-1 ring-slate-200"
/> />
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
...@@ -329,8 +357,8 @@ ...@@ -329,8 +357,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="px-4"> <div class="px-5">
<el-divider class="my-2!" /> <el-divider class="my-1.5! border-slate-100!" />
</div> </div>
</div> </div>
<!-- 底部分页 --> <!-- 底部分页 -->
...@@ -367,6 +395,7 @@ import { ...@@ -367,6 +395,7 @@ import {
addComment, addComment,
getCommentChildren, getCommentChildren,
getSecondCommentList, getSecondCommentList,
topOrCancelTopComment,
} from '@/api' } from '@/api'
import { usePageSearch, useScrollTop } from '@/hooks' import { usePageSearch, useScrollTop } from '@/hooks'
import { ArticleTypeEnum, BooleanFlag } from '@/constants' import { ArticleTypeEnum, BooleanFlag } from '@/constants'
...@@ -382,6 +411,7 @@ import { push } from 'notivue' ...@@ -382,6 +411,7 @@ import { push } from 'notivue'
const { jumpToUserHomePage } = useNavigation() const { jumpToUserHomePage } = useNavigation()
const { const {
authorId = '',
id, id,
defaultSize = 10, defaultSize = 10,
immediate = true, immediate = true,
...@@ -389,6 +419,7 @@ const { ...@@ -389,6 +419,7 @@ const {
commentId = 0, commentId = 0,
type, type,
} = defineProps<{ } = defineProps<{
authorId?: string // 文章作者id
id: number // 文章ID id: number // 文章ID
defaultSize?: number defaultSize?: number
isQuestion?: boolean // 如果是问题的话 展示有点不一样 isQuestion?: boolean // 如果是问题的话 展示有点不一样
...@@ -414,7 +445,9 @@ const isReal = computed( ...@@ -414,7 +445,9 @@ const isReal = computed(
const userAvatar = computed(() => { const userAvatar = computed(() => {
return isReal.value ? userInfo.value.avatar : userInfo.value.hiddenAvatar return isReal.value ? userInfo.value.avatar : userInfo.value.hiddenAvatar
}) })
const isAuthor = computed(() => {
return authorId === userInfo.value.userId
})
const commentRef = useTemplateRef<HTMLElement | null>('commentRef') const commentRef = useTemplateRef<HTMLElement | null>('commentRef')
const commentListDialogRef = useTemplateRef<typeof CommentListDialog>('commentListDialogRef') const commentListDialogRef = useTemplateRef<typeof CommentListDialog>('commentListDialogRef')
const replyToOtherBoxRefList = ref<HTMLElement[]>([]) const replyToOtherBoxRefList = ref<HTMLElement[]>([])
...@@ -498,6 +531,40 @@ const handleLickComment = async (item: CommentItemDto) => { ...@@ -498,6 +531,40 @@ const handleLickComment = async (item: CommentItemDto) => {
} }
} }
const highlightCommentId = ref<number | null>(null)
const topCommentPendingId = ref<number | null>(null)
const handleTopComment = async (item: CommentItemDto) => {
if (topCommentPendingId.value !== null) return
try {
topCommentPendingId.value = item.id
await topOrCancelTopComment(item.id)
const isTopNow = item.isTop === BooleanFlag.YES
push.success(isTopNow ? '取消置顶成功' : '置顶成功')
await refresh()
if (isTopNow) return
await nextTick()
const idx = list.value.findIndex((i: CommentItemDto) => i.id === item.id)
if (idx !== -1) {
await handleBackTopChildren(idx)
highlightCommentId.value = item.id
setTimeout(() => {
if (highlightCommentId.value === item.id) {
highlightCommentId.value = null
}
}, 2400)
}
} catch (error) {
console.error(error)
} finally {
topCommentPendingId.value = null
}
}
const handleReply = (item: CommentItemDto, index: number) => { const handleReply = (item: CommentItemDto, index: number) => {
replyPlaceholder.value = `回复@${item.replyUser}:` replyPlaceholder.value = `回复@${item.replyUser}:`
commentToOther.value = '' commentToOther.value = ''
...@@ -663,4 +730,67 @@ defineExpose({ ...@@ -663,4 +730,67 @@ defineExpose({
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
.top-badge-dot {
animation: topDotPulse 1.8s ease-in-out infinite;
}
@keyframes topDotPulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3);
}
50% {
transform: scale(1.2);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
}
.top-comment-highlight {
position: relative;
overflow: hidden;
animation: commentFlash 2.4s ease-out;
}
.top-comment-highlight::before {
content: '';
position: absolute;
left: 0;
top: 0%;
width: 3px;
height: 100%;
border-radius: 999px;
background: linear-gradient(180deg, rgba(99, 102, 241, 0.95), rgba(129, 140, 248, 0.55));
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.08);
animation: commentBarFlash 2.4s ease-out;
}
@keyframes commentFlash {
0% {
background-color: rgba(99, 102, 241, 0.16);
}
100% {
background-color: transparent;
}
}
@keyframes commentBarFlash {
0% {
opacity: 0;
transform: translateY(6px);
}
20% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
</style> </style>
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
:id="id" :id="id"
v-model:total="articleDetail.replyCount" v-model:total="articleDetail.replyCount"
:type="articleDetail.type" :type="articleDetail.type"
:authorId="articleDetail.createUserId"
/> />
</div> </div>
</div> </div>
......
...@@ -141,6 +141,7 @@ ...@@ -141,6 +141,7 @@
<Transition name="fadeCommentBox" mode="out-in"> <Transition name="fadeCommentBox" mode="out-in">
<Comment <Comment
v-show="item.showComment" v-show="item.showComment"
:authorId="item.createUserId"
:ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)" :ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)"
:id="item.id" :id="item.id"
:total="item.cultureCommentListVo?.childNum || 0" :total="item.cultureCommentListVo?.childNum || 0"
......
...@@ -256,32 +256,52 @@ ...@@ -256,32 +256,52 @@
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="(answer, index) in list" v-for="(answer, index) in list"
:ref="(el) => (answerRefList[index] = el as HTMLElement)"
:key="answer.id" :key="answer.id"
class="bg-white rounded-lg p-6 shadow-sm border border-slate-100 hover:border-slate-200 transition-colors" class="bg-white rounded-lg p-6 shadow-sm border border-slate-100 hover:border-slate-200 transition-colors"
:class="{ 'top-answer-highlight': highlightCommentId === answer.id }"
> >
<!-- 用户头 --> <!-- 用户头 -->
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center justify-between gap-3 mb-3">
<div class="flex items-center gap-3 min-w-0">
<el-avatar <el-avatar
:src="answer.avatar" :src="answer.avatar"
:size="36" :size="36"
class="flex-shrink-0 cursor-pointer" class="flex-shrink-0 cursor-pointer"
@click="jumpToUserHomePage({ userId: answer.userId, type: ArticleTypeEnum.QUESTION })" @click="
jumpToUserHomePage({ userId: answer.userId, type: ArticleTypeEnum.QUESTION })
"
/> />
<div> <div class="min-w-0">
<div class="text-slate-900 text-sm flex items-center gap-2"> <div class="text-slate-900 text-sm flex items-center gap-2">
{{ answer.replyUser }} {{ answer.replyUser }}
<!-- 徽章示例 --> <span
<!-- <span v-if="answer.isTop === 1"
v-if="index === 0" class="inline-flex items-center gap-1 px-2 py-0.5 text-13px leading-4 font-medium text-amber-700 bg-amber-50/80 border border-amber-200/70 rounded-full"
class="px-1.5 py-0.5 bg-yellow-50 text-yellow-600 text-[10px] rounded scale-90 origin-left" >
>优秀回答</span <span
> --> class="top-badge-dot w-1.5 h-1.5 rounded-full bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,0.18)]"
></span>
置顶回答
</span>
</div> </div>
<!-- <div class="text-xs text-slate-500 mt-0.5 max-w-md truncate">
{{ answer.description || '暂无简介' }}
</div> -->
</div> </div>
</div> </div>
<button
v-if="isAuthor"
type="button"
class="group cursor-pointer inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-all duration-200 active:scale-98 disabled:opacity-55 disabled:cursor-not-allowed"
:class="
answer.isTop === 1
? 'text-amber-700 border-amber-300/80 bg-amber-50 hover:bg-amber-100/80'
: 'text-slate-600 border-slate-200 bg-white hover:text-indigo-600 hover:border-indigo-200 hover:bg-indigo-50/70'
"
:disabled="topCommentPendingId !== null"
@click="handleTopAnswer(answer)"
>
{{ answer.isTop === 1 ? '取消置顶' : '置顶回答' }}
</button>
</div>
<!-- 赞同票数 (微小的灰色文字,增加信息密度) --> <!-- 赞同票数 (微小的灰色文字,增加信息密度) -->
<div v-if="answer.postPriseCount" class="text-[14px] text-slate-500 mb-2"> <div v-if="answer.postPriseCount" class="text-[14px] text-slate-500 mb-2">
...@@ -317,7 +337,7 @@ ...@@ -317,7 +337,7 @@
</div> </div>
<!-- 底 部吸附操作栏 --> <!-- 底 部吸附操作栏 -->
<div class="flex items-center gap-4 select-none sticky bottom-0 bg-white"> <div class="flex items-center gap-4 select-none">
<!-- 核心交互:赞同/反对胶囊 --> <!-- 核心交互:赞同/反对胶囊 -->
<div class="flex items-center bg-blue-50/60 rounded-[4px] overflow-hidden"> <div class="flex items-center bg-blue-50/60 rounded-[4px] overflow-hidden">
<button <button
...@@ -357,6 +377,7 @@ ...@@ -357,6 +377,7 @@
class="mt-4 border border-slate-200 rounded-lg bg-slate-50/50 overflow-hidden" class="mt-4 border border-slate-200 rounded-lg bg-slate-50/50 overflow-hidden"
> >
<Comment <Comment
:authorId="questionDetail.createUserId"
:ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)" :ref="(e) => (commentRefList[index] = e as InstanceType<typeof Comment>)"
:id="questionId" :id="questionId"
:total="answer.childrenNum" :total="answer.childrenNum"
...@@ -393,8 +414,9 @@ import { ...@@ -393,8 +414,9 @@ import {
addOrCanceArticlelLike, addOrCanceArticlelLike,
addOrCanceArticlelCollect, addOrCanceArticlelCollect,
addOrCancelCommentLike, addOrCancelCommentLike,
topOrCancelTopComment,
} from '@/api' } from '@/api'
import type { ArticleItemDto } from '@/api' import type { ArticleItemDto, CommentItemDto } from '@/api'
import { usePageSearch } from '@/hooks' import { usePageSearch } from '@/hooks'
import Comment from '@/components/common/Comment/index.vue' import Comment from '@/components/common/Comment/index.vue'
import CommentDialog from '@/components/common/CommentDialog/index.vue' import CommentDialog from '@/components/common/CommentDialog/index.vue'
...@@ -402,9 +424,9 @@ import BackButton from '@/components/common/BackButton/index.vue' ...@@ -402,9 +424,9 @@ import BackButton from '@/components/common/BackButton/index.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useNavigation } from '@/hooks' import { useNavigation, useScrollTop } from '@/hooks'
import { parseEmoji } from '@/utils/emoji' import { parseEmoji } from '@/utils/emoji'
import { ArticleTypeEnum } from '@/constants' import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import { push } from 'notivue' import { push } from 'notivue'
const userStore = useUserStore() const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
...@@ -414,9 +436,12 @@ const router = useRouter() ...@@ -414,9 +436,12 @@ const router = useRouter()
const questionId = Number(route.params.id) const questionId = Number(route.params.id)
const commentRefList = ref<InstanceType<typeof Comment>[]>([]) const commentRefList = ref<InstanceType<typeof Comment>[]>([])
const answerRefList = ref<HTMLElement[]>([])
const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto) const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto)
const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef') const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef')
// 回滚到子评论框
const { handleBackTop: handleBackTopChildren } = useScrollTop(answerRefList)
const loading = computed(() => !questionDetail.value.title) const loading = computed(() => !questionDetail.value.title)
const isAuthor = computed(() => { const isAuthor = computed(() => {
...@@ -479,13 +504,45 @@ const handleCollectArticle = async () => { ...@@ -479,13 +504,45 @@ const handleCollectArticle = async () => {
push.success(`${questionDetail.value.hasCollect ? '收藏成功' : '取消收藏成功'}`) push.success(`${questionDetail.value.hasCollect ? '收藏成功' : '取消收藏成功'}`)
} }
const handleLikeAnswer = async (answer: any) => { const handleLikeAnswer = async (answer: CommentItemDto) => {
await addOrCancelCommentLike(answer.id) await addOrCancelCommentLike(answer.id)
answer.hasPraise = !answer.hasPraise answer.hasPraise = answer.hasPraise === BooleanFlag.YES ? BooleanFlag.NO : BooleanFlag.YES
answer.postPriseCount = answer.hasPraise ? answer.postPriseCount + 1 : answer.postPriseCount - 1 answer.postPriseCount = answer.hasPraise ? answer.postPriseCount + 1 : answer.postPriseCount - 1
push.success(`${answer.hasPraise ? '点赞该回答' : '取消点赞该回答'}`) push.success(`${answer.hasPraise ? '点赞该回答' : '取消点赞该回答'}`)
} }
const topCommentPendingId = ref<number | null>(null)
const highlightCommentId = ref<number | null>(null)
const handleTopAnswer = async (answer: CommentItemDto) => {
if (topCommentPendingId.value !== null) return
try {
topCommentPendingId.value = answer.id
await topOrCancelTopComment(answer.id)
const isTopNow = answer.isTop === BooleanFlag.YES
push.success(isTopNow ? '取消置顶成功' : '置顶成功')
await refresh()
if (isTopNow) return
await nextTick()
const idx = list.value.findIndex((i: CommentItemDto) => i.id === answer.id)
if (idx !== -1) {
await handleBackTopChildren(idx)
highlightCommentId.value = answer.id
setTimeout(() => {
if (highlightCommentId.value === answer.id) {
highlightCommentId.value = null
}
}, 2400)
}
} catch (error) {
console.error(error)
} finally {
topCommentPendingId.value = null
}
}
const handleComment = (answer: any, index: number) => { const handleComment = (answer: any, index: number) => {
commentRefList.value[index]?.search() commentRefList.value[index]?.search()
answer.showComment = !answer.showComment answer.showComment = !answer.showComment
...@@ -528,4 +585,61 @@ onMounted(() => { ...@@ -528,4 +585,61 @@ onMounted(() => {
background-color: #f1f5f9; background-color: #f1f5f9;
} }
} }
.top-badge-dot {
animation: topDotPulse 2.4s ease-in-out infinite;
}
@keyframes topDotPulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.28);
}
50% {
transform: scale(1.2);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
}
}
.top-answer-highlight {
position: relative;
overflow: hidden;
border-left: 3px solid rgba(99, 102, 241, 0.65);
animation: answerFlash 2.4s ease-out;
}
@keyframes answerFlash {
0% {
border-left-color: rgba(99, 102, 241, 0.78);
background-color: rgba(99, 102, 241, 0.12);
}
60% {
border-left-color: rgba(99, 102, 241, 0.52);
background-color: rgba(99, 102, 241, 0.06);
}
100% {
border-left-color: rgba(99, 102, 241, 0.2);
background-color: transparent;
}
}
@keyframes answerBarFlash {
0% {
opacity: 0;
transform: translateY(6px);
}
20% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
</style> </style>
...@@ -302,6 +302,7 @@ ...@@ -302,6 +302,7 @@
</div> </div>
<Comment <Comment
:authorId="videoDetail.createUserId"
ref="commentRef" ref="commentRef"
:id="videoId" :id="videoId"
v-model:total="videoDetail.replyCount" v-model:total="videoDetail.replyCount"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment