3 changed files with 268 additions and 0 deletions
@ -0,0 +1,266 @@ |
|||
<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> |
|||
Loading…
Reference in new issue