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