mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-07-27 04:20:43 +08:00
9c028bdc1b
优化评论列表加载状态的视觉表现,增加黑色加载图标与加载中文案提示
407 lines
14 KiB
Vue
407 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { createComment, createReply, listCommentReplies, listComments } from '@/api/halo-base/comment'
|
||
import { completeUrl } from '@/utils/url'
|
||
import { avatar } from '@/utils/imageHelper'
|
||
import { timeFrom } from '@/utils/base/timeFrom'
|
||
import type { CommentV1alpha1PublicApiListCommentRepliesRequest, CommentV1alpha1PublicApiListComments1Request, CommentWithReplyVo, ReplyVo } from '@halo-dev/api-client'
|
||
import type { IHaloListResponseBase } from '@/api/types/halo'
|
||
|
||
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 commentList = ref<CommentWithReplyVo[]>([])
|
||
const commentPage = ref(1)
|
||
const commentHasNext = ref(false)
|
||
const commentLoading = ref(false)
|
||
|
||
// --- 回复目标 ---
|
||
const replyTarget = ref<{
|
||
commentName: string
|
||
replyName?: string
|
||
displayName: 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>>({})
|
||
|
||
// --- 获取评论列表 ---
|
||
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) {
|
||
replyTarget.value = { commentName, replyName, displayName }
|
||
}
|
||
|
||
// --- 取消回复 ---
|
||
function handleCancelReply() {
|
||
replyTarget.value = null
|
||
}
|
||
|
||
// --- 提交评论/回复 ---
|
||
async function handleSubmit() {
|
||
const text = inputText.value.trim()
|
||
if (!text || submitLoading.value)
|
||
return
|
||
|
||
submitLoading.value = true
|
||
try {
|
||
if (replyTarget.value) {
|
||
await createReply({
|
||
name: replyTarget.value.commentName,
|
||
replyRequest: {
|
||
content: text,
|
||
raw: text,
|
||
quoteReply: replyTarget.value.replyName || undefined,
|
||
allowNotification: true,
|
||
},
|
||
} 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,
|
||
},
|
||
} 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 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 />
|
||
</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-1"
|
||
@click="handleReply(comment.metadata.name, undefined, comment.owner.displayName || '匿名用户')"
|
||
>
|
||
<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 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="rounded-lg bg-gray-100 px-2.5 py-1.5">
|
||
<text class="text-[11px] text-gray-400">回复 {{ reply.spec.quoteReply }}:</text>
|
||
</view>
|
||
|
||
<text class="text-xs text-gray-700 leading-4.5">
|
||
<rich-text :nodes="reply.spec.raw" />
|
||
</text>
|
||
|
||
<view
|
||
class="flex items-center gap-x-1"
|
||
@click="handleReply(comment.metadata.name, reply.metadata.name, reply.owner.displayName || '匿名用户')"
|
||
>
|
||
<wd-icon name="message" color="#9CA3AF" :size="12" />
|
||
<text class="text-[10px] text-gray-400">回复</text>
|
||
</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" > 加载中 </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 justify-between rounded-lg bg-gray-50 px-3 py-1.5">
|
||
<text class="text-xs text-gray-500">回复 {{ replyTarget.displayName }}</text>
|
||
<view @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>
|
||
</template>
|