1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-07-27 04:20:43 +08:00
Files
uni-halo/src/components/post-detail/uh-post-comment-panel.vue
T
小莫唐尼 a9e649e110 feat: 新增点赞、游客评论、分享等核心功能,重构页面逻辑
1. 新增全局点赞状态管理,使用metadata.name做key并持久化存储
2. 新增游客信息存储与评论面板,支持游客评论互动
3. 新增全局分享hook,为多个页面配置分享功能
4. 重构登录页面,支持登录/注册切换与游客访问模式
5. 合并注册页到登录页,简化路由结构
6. 优化剪贴板工具函数,支持自定义提示文本
7. 重构个人中心页面,支持游客信息管理
8. 修复相册页面标题与样式问题
9. 移除无用的备份文件与测试代码
2026-06-16 00:43:48 +08:00

528 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { createComment, createReply, listCommentReplies, listComments } from '@/api/halo-base/comment'
import { avatar } from '@/utils/imageHelper'
import { timeFrom } from '@/utils/base/timeFrom'
import { useGuestStore } from '@/store/guest'
import { useUpvoteStore } from '@/store/upvote'
import type { CommentV1alpha1PublicApiListCommentRepliesRequest, CommentV1alpha1PublicApiListComments1Request, CommentWithReplyVo, ReplyVo } from '@halo-dev/api-client'
import type { IHaloListResponseBase } from '@/api/types/halo'
import UhGuestInfoPanel from './uh-guest-info-panel.vue'
interface IProps {
postName: string
commentSubjectVersion: string
commentCount?: number
}
const props = withDefaults(defineProps<IProps>(), {
commentCount: 0,
})
const emits = defineEmits<{
(e: 'close'): void
(e: 'commented'): void
}>()
const guestStore = useGuestStore()
const upvoteStore = useUpvoteStore()
// --- 评论列表 ---
const commentList = ref<CommentWithReplyVo[]>([])
const commentPage = ref(1)
const commentHasNext = ref(false)
const commentLoading = ref(false)
// --- 回复目标 ---
const replyTarget = ref<{
commentName: string
replyName?: string
displayName: string
rawContent: string
} | null>(null)
// --- 输入内容 ---
const inputText = ref('')
const submitLoading = ref(false)
// --- 展开回复的评论 ---
const expandedReplies = ref<Set<string>>(new Set())
const replyLoadingMap = ref<Record<string, boolean>>({})
const replyHasNextMap = ref<Record<string, boolean>>({})
const replyPageMap = ref<Record<string, number>>({})
// --- 游客信息面板 ---
const showGuestPanel = ref(false)
// --- 点赞(切换) ---
async function handleUpvote(name: string) {
const nowUpvoted = await upvoteStore.toggle(name, 'comments')
// 更新本地计数
for (const comment of commentList.value) {
if (comment.metadata.name === name) {
comment.stats.upvote = Math.max(0, (comment.stats.upvote ?? 0) + (nowUpvoted ? 1 : -1))
break
}
const reply = comment.replies?.items?.find(r => r.metadata.name === name)
if (reply) {
reply.stats.upvote = Math.max(0, (reply.stats.upvote ?? 0) + (nowUpvoted ? 1 : -1))
break
}
}
}
// --- 获取评论列表 ---
async function fetchComments(page = 1, append = false) {
commentLoading.value = true
try {
const res = await listComments({
group: 'content.halo.run',
name: props.postName,
kind: 'Post',
version: props.commentSubjectVersion,
page,
size: 20,
sort: ['metadata.creationTimestamp,desc'],
} as CommentV1alpha1PublicApiListComments1Request)
const data = res as unknown as IHaloListResponseBase<CommentWithReplyVo>
if (append) {
commentList.value = [...commentList.value, ...(data.items ?? [])]
}
else {
commentList.value = data.items ?? []
}
commentHasNext.value = data.hasNext
commentPage.value = page
}
catch (err) {
console.error('获取评论失败:', err)
}
finally {
commentLoading.value = false
}
}
// --- 加载更多评论 ---
function handleLoadMoreComments() {
if (commentLoading.value || !commentHasNext.value)
return
fetchComments(commentPage.value + 1, true)
}
// --- 获取回复列表 ---
async function fetchReplies(commentName: string, page = 1, append = false) {
replyLoadingMap.value[commentName] = true
try {
const res = await listCommentReplies({
name: commentName,
page,
size: 10,
} as CommentV1alpha1PublicApiListCommentRepliesRequest)
const data = res as unknown as IHaloListResponseBase<ReplyVo>
const comment = commentList.value.find(c => c.metadata.name === commentName)
if (comment) {
if (append) {
comment.replies = {
...comment.replies,
items: [...(comment.replies?.items ?? []), ...(data.items ?? [])],
hasNext: data.hasNext,
} as any
}
else {
comment.replies = {
...data,
items: data.items ?? [],
} as any
}
}
replyHasNextMap.value[commentName] = data.hasNext
replyPageMap.value[commentName] = page
}
catch (err) {
console.error('获取回复失败:', err)
uni.showToast({ title: '获取回复失败', icon: 'none' })
}
finally {
replyLoadingMap.value[commentName] = false
}
}
// --- 切换展开回复 ---
function handleToggleReplies(comment: CommentWithReplyVo) {
const name = comment.metadata.name
if (expandedReplies.value.has(name)) {
expandedReplies.value.delete(name)
}
else {
expandedReplies.value.add(name)
if (!comment.replies?.items?.length) {
fetchReplies(name)
}
}
}
// --- 加载更多回复 ---
function handleLoadMoreReplies(commentName: string) {
if (replyLoadingMap.value[commentName] || !replyHasNextMap.value[commentName])
return
fetchReplies(commentName, (replyPageMap.value[commentName] ?? 1) + 1, true)
}
// --- 设置回复目标 ---
function handleReply(commentName: string, replyName: string | undefined, displayName: string, rawContent: string) {
replyTarget.value = { commentName, replyName, displayName, rawContent }
}
// --- 取消回复 ---
function handleCancelReply() {
replyTarget.value = null
}
// --- 提交评论/回复 ---
function handleSubmit() {
const text = inputText.value.trim()
if (!text || submitLoading.value)
return
// 检查游客信息,未设置直接弹出面板
if (!guestStore.isComplete) {
showGuestPanel.value = true
return
}
doSubmit(text)
}
async function doSubmit(text: string) {
submitLoading.value = true
try {
const owner = {
displayName: guestStore.info.displayName,
email: guestStore.info.email,
website: guestStore.info.website || undefined,
}
if (replyTarget.value) {
await createReply({
name: replyTarget.value.commentName,
replyRequest: {
content: text,
raw: text,
quoteReply: replyTarget.value.replyName || undefined,
allowNotification: true,
owner,
},
} as any)
}
else {
await createComment({
commentRequest: {
content: text,
raw: text,
subjectRef: {
group: 'content.halo.run',
kind: 'Post',
name: props.postName,
version: props.commentSubjectVersion,
},
allowNotification: true,
hidden: false,
owner,
},
} as any)
}
inputText.value = ''
replyTarget.value = null
uni.showToast({ title: '评论成功', icon: 'none' })
await fetchComments(1)
emits('commented')
}
catch (err) {
console.error('评论失败:', err)
uni.showToast({ title: '评论失败', icon: 'none' })
}
finally {
submitLoading.value = false
}
}
// --- 游客信息保存后自动提交 ---
function handleGuestSaved() {
showGuestPanel.value = false
// 保存后如果有输入内容,自动提交
const text = inputText.value.trim()
if (text) {
doSubmit(text)
}
}
// --- 查找被引用的回复 ---
function findQuotedReply(comment: CommentWithReplyVo, quoteReplyName: string): ReplyVo | undefined {
return comment.replies?.items?.find(r => r.metadata.name === quoteReplyName)
}
// --- 关闭面板 ---
function handleClose() {
emits('close')
}
defineExpose({
refresh: () => fetchComments(),
})
onMounted(() => {
fetchComments()
})
</script>
<template>
<view class="uh-backdrop-blur fixed inset-0 z-110 flex items-end bg-black/20" @click="handleClose">
<wd-transition :show="true" :duration="300" name="slide-up" :lazy-render="false">
<!-- 面板主体 -->
<view
class="box-border w-screen flex flex-col rounded-t-3xl bg-white"
@click.stop
>
<!-- 拖拽指示条 -->
<view class="w-full center py-2" @click.stop="handleClose">
<view class="h-1 w-10 rounded-full bg-gray-300" />
</view>
<!-- 头部 -->
<view class="box-border flex items-center justify-between border-b border-gray-100 px-4 pb-3">
<text class="text-base text-gray-900 font-bold">
说点什么 <text v-if="commentCount > 0" class="text-sm text-gray-600 font-normal">{{ commentCount }}</text>
</text>
<view class="h-8 w-8 center rounded-full bg-gray-50" @click="handleClose">
<wd-icon name="close" color="#6B7280" :size="18" />
</view>
</view>
<!-- 评论列表 -->
<scroll-view
class="h-[60vh] overflow-hidden"
scroll-y
@scrolltolower="handleLoadMoreComments"
>
<view class="box-border h-full px-4 pt-3">
<!-- 加载中 -->
<view v-if="commentLoading && commentList.length === 0" class="box-border h-full center">
<wd-loading :size="32" color="#1a1a1a">
加载中...
</wd-loading>
</view>
<!-- 空状态 -->
<view v-else-if="!commentLoading && commentList.length === 0" class="box-border h-full center flex-col gap-y-2">
<wd-icon name="chat" color="#D1D5DB" :size="40" />
<text class="text-sm text-gray-400">快来抢沙发吧~</text>
</view>
<!-- 评论列表 -->
<view v-else class="box-border h-full w-full flex flex-col">
<view
v-for="comment in commentList"
:key="comment.metadata.name"
class="border-b border-gray-50 py-3"
>
<view class="flex gap-x-3">
<!-- 头像 -->
<image
class="uh-shadow-md box-border h-9 w-9 shrink-0 border-2 border-white rounded-xl border-solid bg-gray-100"
:src="avatar(comment.owner.avatar)"
mode="aspectFill"
/>
<!-- 内容区 -->
<view class="flex flex-1 flex-col gap-y-2 overflow-hidden">
<view class="flex items-center justify-between">
<text class="text-xs text-gray-500 font-medium">{{ comment.owner.displayName || '匿名用户' }}</text>
<text class="text-[11px] text-gray-400">{{ timeFrom(comment.spec.creationTime || comment.metadata.creationTimestamp) }}</text>
</view>
<text class="text-sm text-gray-900 leading-5">
<rich-text :nodes="comment.spec.raw" />
</text>
<!-- 操作栏 -->
<view class="flex items-center gap-x-4">
<!-- 点赞 -->
<view
class="flex items-center gap-x-0.5"
@click="handleUpvote(comment.metadata.name)"
>
<wd-icon
name="thumb-up"
:color="upvoteStore.isUpvoted(comment.metadata.name) ? '#EF4444' : '#9CA3AF'"
:size="14"
/>
<text
class="text-[11px]"
:class="upvoteStore.isUpvoted(comment.metadata.name) ? 'text-red-500' : 'text-gray-400'"
>
{{ comment.stats.upvote || '' }}
</text>
</view>
<!-- 回复 -->
<view
class="flex items-center gap-x-1"
@click="handleReply(comment.metadata.name, undefined, comment.owner.displayName || '匿名用户', comment.spec.raw)"
>
<wd-icon name="message" color="#9CA3AF" :size="14" />
<text class="text-[11px] text-gray-400">回复</text>
</view>
<!-- 展开回复 -->
<view
v-if="(comment.status?.replyCount ?? 0) > 0"
class="flex items-center gap-x-1"
@click="handleToggleReplies(comment)"
>
<wd-icon
:name="expandedReplies.has(comment.metadata.name) ? 'up' : 'down'"
color="#9CA3AF"
:size="14"
/>
<text class="text-[11px] text-gray-400">
{{ expandedReplies.has(comment.metadata.name) ? '收起' : `${comment.status?.replyCount}条回复` }}
</text>
</view>
</view>
<!-- 回复列表 -->
<view v-if="expandedReplies.has(comment.metadata.name)" class="mt-2 flex flex-col gap-y-2.5 rounded-xl bg-gray-50 p-3">
<view
v-for="reply in (comment.replies?.items ?? [])"
:key="reply.metadata.name"
class="flex gap-x-2.5"
>
<image
class="h-7 w-7 shrink-0 border-2 border-white rounded-lg border-solid bg-gray-200"
:src="avatar(reply.owner.avatar)"
mode="aspectFill"
/>
<view class="flex flex-1 flex-col gap-y-1.5 overflow-hidden">
<view class="flex items-center justify-between">
<text class="text-[11px] text-gray-500 font-medium">{{ reply.owner.displayName || '匿名用户' }}</text>
<text class="text-[10px] text-gray-400">{{ timeFrom(reply.spec.creationTime || reply.metadata.creationTimestamp) }}</text>
</view>
<view v-if="reply.spec.quoteReply" class="box-border rounded-lg bg-gray-100 px-2.5 py-1.5">
<view v-if="findQuotedReply(comment, reply.spec.quoteReply)" class="w-full flex items-center gap-x-1 text-[11px] text-gray-400">
<text class="shrink-0">回复 {{ findQuotedReply(comment, reply.spec.quoteReply)?.owner.displayName || '匿名用户' }}</text> <rich-text class="line-clamp-1" :nodes="findQuotedReply(comment, reply.spec.quoteReply)?.spec.raw" />
</view>
<view v-else class="line-clamp-1 w-full flex items-center gap-x-1 text-[11px] text-gray-400">
<text class="shrink-0">回复 {{ comment.owner.displayName || '匿名用户' }}</text> <rich-text class="line-clamp-1" :nodes="comment.spec.raw" />
</view>
</view>
<text class="text-xs text-gray-900 leading-4.5">
<rich-text :nodes="reply.spec.raw" />
</text>
<!-- 回复操作栏 -->
<view class="flex items-center gap-x-3">
<!-- 点赞 -->
<view
class="flex items-center gap-x-0.5"
@click="handleUpvote(reply.metadata.name)"
>
<wd-icon
name="thumb-up"
:color="upvoteStore.isUpvoted(reply.metadata.name) ? '#EF4444' : '#9CA3AF'"
:size="12"
/>
<text
class="text-[10px]"
:class="upvoteStore.isUpvoted(reply.metadata.name) ? 'text-red-500' : 'text-gray-400'"
>
{{ reply.stats.upvote || '' }}
</text>
</view>
<!-- 回复 -->
<view
class="flex items-center gap-x-1"
@click="handleReply(comment.metadata.name, reply.metadata.name, reply.owner.displayName || '匿名用户', reply.spec.raw)"
>
<wd-icon name="message" color="#9CA3AF" :size="12" />
<text class="text-[10px] text-gray-400">回复</text>
</view>
</view>
</view>
</view>
<!-- 加载更多回复 -->
<view
v-if="replyHasNextMap[comment.metadata.name]"
class="center py-1"
@click="handleLoadMoreReplies(comment.metadata.name)"
>
<text class="text-xs text-gray-400">
{{ replyLoadingMap[comment.metadata.name] ? '加载中...' : '查看更多回复' }}
</text>
</view>
<view v-if="replyLoadingMap[comment.metadata.name] && !(comment.replies?.items?.length)" class="center py-2">
<wd-loading :size="20" color="#1a1a1a" direction="horizontal">
加载中
</wd-loading>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="commentHasNext" class="center py-3" @click="handleLoadMoreComments">
<text class="text-xs text-gray-400">{{ commentLoading ? '加载中...' : '加载更多评论' }}</text>
</view>
<view class="h-4 w-full" />
</view>
</view>
</scroll-view>
<!-- 底部输入区 -->
<view class="box-border border-t border-gray-100 px-4 pt-2">
<!-- 回复提示 -->
<view v-if="replyTarget" class="mb-2 flex items-center gap-x-2 rounded-lg bg-gray-50 px-3 py-1.5">
<view class="flex flex-1 flex-col overflow-hidden">
<text class="text-[11px] text-gray-400 font-medium">回复 {{ replyTarget.displayName }}</text>
<view class="line-clamp-1 text-[11px] text-gray-500">
<rich-text :nodes="replyTarget.rawContent" />
</view>
</view>
<view class="shrink-0" @click="handleCancelReply">
<wd-icon name="close" color="#9CA3AF" :size="14" />
</view>
</view>
<view class="flex items-center gap-x-3 pb-2">
<input
v-model="inputText"
class="flex-1 rounded-xl bg-gray-50 px-4 py-2.5 text-sm active:bg-gray-100 focus:bg-gray-100"
:placeholder="replyTarget ? `回复 ${replyTarget.displayName}...` : '说点什么吧...'"
confirm-type="send"
:adjust-position="true"
@confirm="handleSubmit"
>
<view
class="h-9 w-9 center shrink-0 rounded-xl transition-colors"
:class="inputText.trim() ? 'bg-gray-900' : 'bg-gray-200'"
@click="handleSubmit"
>
<wd-icon name="arrow-up" :color="inputText.trim() ? '#FFFFFF' : '#9CA3AF'" :size="18" />
</view>
</view>
</view>
</view>
</wd-transition>
</view>
<!-- 游客信息面板 -->
<uh-guest-info-panel
v-if="showGuestPanel"
@close="showGuestPanel = false"
@saved="handleGuestSaved"
/>
</template>