You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

266 lines
7.4 KiB

<script setup lang="ts">
import { unwrapApiBody, request, type ApiResponse } from '~/utils/http/factory'
import { useAuthSession } from '~/composables/useAuthSession'
type CommentNode = {
id: number
parentId: number | null
kind: string
guestDisplayName: string | null
author: { nickname: string | null; username: string | null } | null
body: string | null
deleted: boolean
createdAt: string
children: CommentNode[]
viewerCanDelete: boolean
}
type CommentsPayload = {
postId: number
viewerCanModeratePost: boolean
comments: CommentNode[]
}
const props = defineProps<{
mode: 'public-post' | 'unlisted'
publicSlug: string
postSlug?: string
shareToken?: string
}>()
const { loggedIn, refresh: refreshAuthSession } = useAuthSession()
const toast = useToast()
onMounted(() => {
void refreshAuthSession()
})
const commentsBase = computed(() => {
if (props.mode === 'public-post') {
const slug = props.postSlug
if (!slug) {
return ''
}
return `/api/public/profile/${encodeURIComponent(props.publicSlug)}/posts/${encodeURIComponent(slug)}`
}
const token = props.shareToken
if (!token) {
return ''
}
return `/api/public/unlisted/${encodeURIComponent(props.publicSlug)}/${encodeURIComponent(token)}`
})
const { data, pending, error, refresh: refreshComments } = await useAsyncData(
() => `comments-${props.mode}-${props.publicSlug}-${props.postSlug ?? ''}-${props.shareToken ?? ''}`,
async () => {
const base = commentsBase.value
if (!base) {
return null
}
const fetcher = import.meta.server ? useRequestFetch() : $fetch
const res = await fetcher<ApiResponse<CommentsPayload>>(`${base}/comments`)
return unwrapApiBody(res)
},
{ watch: [commentsBase] },
)
const flatComments = computed(() => flattenTree(data.value?.comments ?? []))
function flattenTree(nodes: CommentNode[], depth = 0): Array<{ node: CommentNode; depth: number }> {
const acc: Array<{ node: CommentNode; depth: number }> = []
for (const n of nodes) {
acc.push({ node: n, depth })
if (n.children.length > 0) {
acc.push(...flattenTree(n.children, depth + 1))
}
}
return acc
}
const replyToId = ref<number | null>(null)
const draftBody = ref('')
const guestName = ref('')
const submitting = ref(false)
function authorLine(node: CommentNode): string {
if (node.deleted) {
return ''
}
if (node.kind === 'guest' && node.guestDisplayName) {
return node.guestDisplayName
}
if (node.author) {
return node.author.nickname || node.author.username || '用户'
}
return '访客'
}
function extractErrorMessage(e: unknown): string {
if (e && typeof e === 'object') {
const fe = e as {
statusMessage?: string
message?: string
data?: { message?: string }
}
if (typeof fe.statusMessage === 'string' && fe.statusMessage.length) {
return fe.statusMessage
}
if (typeof fe.data?.message === 'string' && fe.data.message.length) {
return fe.data.message
}
if (typeof fe.message === 'string' && fe.message.length) {
return fe.message
}
}
if (e instanceof Error) {
return e.message
}
return '操作失败'
}
async function deleteComment(node: CommentNode) {
const postId = data.value?.postId
if (postId == null) {
return
}
try {
const res = await request<ApiResponse<{ ok: boolean }>>(
`/api/me/posts/${postId}/comments/${node.id}`,
{ method: 'DELETE' },
)
unwrapApiBody(res)
await refreshComments()
}
catch (e: unknown) {
toast.add({ title: extractErrorMessage(e), color: 'error' })
}
}
async function submitComment() {
const base = commentsBase.value
if (!base) {
return
}
const body = draftBody.value.trim()
if (!body) {
toast.add({ title: '请填写内容', color: 'error' })
return
}
if (!loggedIn.value) {
const name = guestName.value.trim()
if (!name) {
toast.add({ title: '请填写昵称', color: 'error' })
return
}
}
submitting.value = true
try {
const payload = {
parentId: replyToId.value,
body: draftBody.value,
...(loggedIn.value ? {} : { guestDisplayName: guestName.value.trim() }),
}
if (loggedIn.value) {
const res = await request<ApiResponse<{ id: number }>>(`${base}/comments`, {
method: 'POST',
body: payload,
})
unwrapApiBody(res)
}
else {
const res = await $fetch<ApiResponse<{ id: number }>>(`${base}/comments`, {
method: 'POST',
body: payload,
credentials: 'include',
})
unwrapApiBody(res)
}
draftBody.value = ''
guestName.value = ''
replyToId.value = null
await refreshComments()
}
catch (e: unknown) {
toast.add({ title: extractErrorMessage(e), color: 'error' })
}
finally {
submitting.value = false
}
}
</script>
<template>
<section class="space-y-4 border-t border-default pt-8">
<h2 class="text-lg font-semibold">
评论
</h2>
<p v-if="pending" class="text-muted">
加载评论…
</p>
<p v-else-if="error" class="text-muted">
评论加载失败
</p>
<div v-else class="space-y-3">
<div
v-for="{ node, depth } in flatComments"
:key="node.id"
:class="['space-y-2 py-1', depth > 0 && 'border-l border-default']"
:style="depth > 0 ? { marginLeft: `${depth * 0.75}rem`, paddingLeft: '0.75rem' } : undefined"
>
<template v-if="node.deleted">
<p class="text-sm text-muted">
此评论已删除
</p>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-medium">{{ authorLine(node) }}</span>
<span class="text-xs tabular-nums text-muted">{{ new Date(node.createdAt).toLocaleString('zh-CN') }}</span>
<UButton
size="xs"
variant="ghost"
color="neutral"
label="回复"
@click="replyToId = node.id"
/>
<UButton
v-if="node.viewerCanDelete"
size="xs"
color="error"
variant="soft"
label="删除"
@click="deleteComment(node)"
/>
</div>
<div class="prose dark:prose-invert whitespace-pre-wrap text-sm">
{{ node.body }}
</div>
</template>
</div>
<p v-if="!flatComments.length" class="text-sm text-muted">
暂无评论,来抢沙发吧。
</p>
</div>
<div class="space-y-3 border-t border-default pt-6">
<p v-if="replyToId != null" class="flex flex-wrap items-center gap-2 text-sm text-muted">
<span>正在回复评论 #{{ replyToId }}</span>
<UButton size="xs" variant="link" color="neutral" label="取消" @click="replyToId = null" />
</p>
<template v-if="loggedIn">
<UTextarea v-model="draftBody" placeholder="写下你的评论…" :rows="4" class="w-full" />
<UButton :loading="submitting" label="发表" @click="submitComment" />
</template>
<template v-else>
<UInput v-model="guestName" placeholder="你的昵称" class="w-full max-w-md" />
<UTextarea v-model="draftBody" placeholder="写下你的评论…" :rows="4" class="w-full" />
<UButton :loading="submitting" label="发表" @click="submitComment" />
</template>
</div>
</section>
</template>