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.3 KiB
266 lines
7.3 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="depth > 0 ? 'ml-4 border-l border-default pl-3' : ''"
|
|
class="space-y-2 py-1"
|
|
>
|
|
<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>
|
|
|