feat(comments): migrate to backend API
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user