feat(comments): migrate to backend API

This commit is contained in:
zetaloop
2026-03-01 22:40:29 +08:00
parent 505d9c0168
commit 236c1a24da
4 changed files with 169 additions and 69 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id:
initialLiked={post.liked} initialLiked={post.liked}
initialLikeCount={post.likeCount} initialLikeCount={post.likeCount}
/> />
<PostCommentCount postId={post.id} /> <PostCommentCount count={post.commentCount} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+2 -7
View File
@@ -1,17 +1,12 @@
"use client" "use client"
import { useCommentStore } from "@/store/comments"
import { MessageCircle } from "lucide-react" import { MessageCircle } from "lucide-react"
interface PostCommentCountProps { interface PostCommentCountProps {
postId: string count: number
} }
export function PostCommentCount({ postId }: PostCommentCountProps) { export function PostCommentCount({ count }: PostCommentCountProps) {
const count = useCommentStore(
(state) => state.comments.filter((comment) => comment.postId === postId).length,
)
return ( return (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
+91 -15
View File
@@ -1,10 +1,12 @@
"use client" "use client"
import { addComment, toggleCommentLike } from "@/lib/api/comments" import { addComment, listCommentsByPost, toggleCommentLike } from "@/lib/api/comments"
import { toApiError } from "@/lib/errors"
import { notifyInfo } from "@/lib/toast"
import type { Comment } from "@/lib/types"
import { useRequireAuth } from "@/lib/use-require-auth" import { useRequireAuth } from "@/lib/use-require-auth"
import { useCommentStore } from "@/store/comments"
import { Heart } from "lucide-react" import { Heart } from "lucide-react"
import { useMemo, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea" import { Textarea } from "./ui/textarea"
@@ -14,28 +16,58 @@ interface PostCommentsProps {
} }
export function PostComments({ postId }: PostCommentsProps) { export function PostComments({ postId }: PostCommentsProps) {
const allComments = useCommentStore((state) => state.comments) const [comments, setComments] = useState<Comment[]>([])
const comments = useMemo( const [loading, setLoading] = useState(true)
() => allComments.filter((comment) => comment.postId === postId), const [loadError, setLoadError] = useState<string | null>(null)
[allComments, postId],
)
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [submitting, setSubmitting] = useState(false)
const [pendingLike, setPendingLike] = useState<Record<string, boolean>>({})
const { requireAuth } = useRequireAuth() const { requireAuth } = useRequireAuth()
const refresh = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const items = await listCommentsByPost(postId)
setComments(items)
} catch (err: unknown) {
setLoadError(toApiError(err).msg)
} finally {
setLoading(false)
}
}, [postId])
useEffect(() => {
void refresh()
}, [refresh])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="font-semibold"> ({comments.length})</h2> <h2 className="font-semibold">
({loading && comments.length === 0 ? "..." : comments.length})
</h2>
<form <form
className="flex gap-3" className="flex gap-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
requireAuth(() => { requireAuth(async () => {
if (submitting) return
const nextContent = content.trim() const nextContent = content.trim()
if (!nextContent) return if (!nextContent) return
const decision = addComment(postId, nextContent)
if (!decision.ok) return setSubmitting(true)
const decision = await addComment(postId, nextContent)
if (!decision.ok) {
notifyInfo(decision.error.msg)
setSubmitting(false)
return
}
setContent("") setContent("")
await refresh()
setSubmitting(false)
}) })
}} }}
> >
@@ -46,12 +78,16 @@ export function PostComments({ postId }: PostCommentsProps) {
value={content} value={content}
onChange={(event) => setContent(event.target.value)} onChange={(event) => setContent(event.target.value)}
/> />
<Button className="self-end" disabled={!content.trim()}> <Button className="self-end" disabled={!content.trim() || submitting}>
</Button> </Button>
</form> </form>
{comments.length === 0 ? ( {loading && comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">...</p>
) : loadError ? (
<p className="text-sm text-muted-foreground text-center py-8">{loadError}</p>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"></p> <p className="text-sm text-muted-foreground text-center py-8"></p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -71,10 +107,50 @@ export function PostComments({ postId }: PostCommentsProps) {
<p className="text-sm">{comment.content}</p> <p className="text-sm">{comment.content}</p>
<button <button
type="button" type="button"
disabled={pendingLike[comment.id]}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1 transition-colors" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1 transition-colors"
onClick={() => onClick={() =>
requireAuth(() => { requireAuth(() => {
toggleCommentLike(comment.id) if (pendingLike[comment.id]) return
const prevLiked = comment.liked
const prevCount = comment.likeCount
const nextLiked = !prevLiked
setComments((current) =>
current.map((item) => {
if (item.id !== comment.id) return item
return {
...item,
liked: nextLiked,
likeCount: Math.max(0, prevCount + (nextLiked ? 1 : -1)),
}
}),
)
setPendingLike((current) => ({ ...current, [comment.id]: true }))
toggleCommentLike(comment.id, prevLiked)
.then((decision) => {
if (!decision.ok) {
throw decision.error
}
})
.catch((err: unknown) => {
setComments((current) =>
current.map((item) => {
if (item.id !== comment.id) return item
return {
...item,
liked: prevLiked,
likeCount: prevCount,
}
}),
)
notifyInfo(toApiError(err).msg)
})
.finally(() => {
setPendingLike((current) => ({ ...current, [comment.id]: false }))
})
}) })
} }
> >
+75 -46
View File
@@ -1,56 +1,85 @@
import { addNotification } from "@/lib/api/notifications"
import { allow, deny } from "@/lib/decision" import { allow, deny } from "@/lib/decision"
import { useAuthStore } from "@/store/auth" import { toApiError } from "@/lib/errors"
import { useCommentStore } from "@/store/comments" import type { Comment } from "@/lib/types"
import { usePostStore } from "@/store/posts"
export function listComments() { import { httpJson } from "./http"
return useCommentStore.getState().comments
type Paginated<T> = {
items: T[]
meta: {
total: number
offset: number
limit: number
}
} }
export function listCommentsByPost(postId: string) { type ListOptions = {
return useCommentStore.getState().comments.filter((comment) => comment.postId === postId) offset?: number
limit?: number
} }
export function addComment(postId: string, content: string) { function withOffsetLimit(path: string, options?: ListOptions): string {
const user = useAuthStore.getState().user const offset = options?.offset ?? 0
if (!user) { const limit = options?.limit ?? 1000
return deny(401, "请先登录") const searchParams = new URLSearchParams({
} offset: String(offset),
limit: String(limit),
const post = usePostStore.getState().posts.find((item) => item.id === postId)
if (!post) {
return deny(404, "帖子不存在")
}
const comment = useCommentStore.getState().addComment(postId, user, content)
if (!comment) {
return deny(400, "评论内容不能为空")
}
usePostStore.getState().incrementCommentCount(postId)
addNotification({
type: "community",
title: "帖子收到新评论",
content: `${post.title}》有新的评论`,
link: `/post/${post.id}`,
}) })
return `${path}?${searchParams.toString()}`
return allow()
} }
export function toggleCommentLike(commentId: string) { export async function listCommentsByPost(
const user = useAuthStore.getState().user postId: string,
if (!user) { options?: ListOptions,
return deny(401, "请先登录") ): Promise<Comment[]> {
} const encodedId = encodeURIComponent(postId)
const res = await httpJson<Paginated<Comment>>(
const comment = useCommentStore.getState().comments.find((item) => item.id === commentId) withOffsetLimit(`/api/v1/posts/${encodedId}/comments`, options),
if (!comment) { {
return deny(404, "评论不存在") cache: "no-store",
} },
)
useCommentStore.getState().toggleCommentLike(commentId) return res.items
return allow() }
export async function addComment(postId: string, content: string) {
const encodedId = encodeURIComponent(postId)
try {
await httpJson<unknown>(`/api/v1/posts/${encodedId}/comments`, {
method: "POST",
cache: "no-store",
json: { content },
})
return allow()
} catch (err: unknown) {
if (err instanceof Error && err.message === "UNAUTHORIZED") {
return deny(401, "请先登录")
}
const apiError = toApiError(err)
return deny(apiError.code, apiError.msg)
}
}
export async function toggleCommentLike(commentId: string, currentlyLiked: boolean) {
const encodedId = encodeURIComponent(commentId)
try {
if (currentlyLiked) {
await httpJson<unknown>(`/api/v1/comments/${encodedId}/like`, {
method: "DELETE",
cache: "no-store",
})
} else {
await httpJson<unknown>(`/api/v1/comments/${encodedId}/like`, {
method: "POST",
cache: "no-store",
})
}
return allow()
} catch (err: unknown) {
if (err instanceof Error && err.message === "UNAUTHORIZED") {
return deny(401, "请先登录")
}
const apiError = toApiError(err)
return deny(apiError.code, apiError.msg)
}
} }