diff --git a/app/(main)/post/[id]/page.tsx b/app/(main)/post/[id]/page.tsx index 251041d..628d5e8 100644 --- a/app/(main)/post/[id]/page.tsx +++ b/app/(main)/post/[id]/page.tsx @@ -1,14 +1,15 @@ -import { ArrowLeft, MessageCircle, Pin, Star } from "lucide-react" +import { ArrowLeft, Pin, Star } from "lucide-react" import Image from "next/image" import Link from "next/link" import { notFound } from "next/navigation" +import { PostCommentCount } from "@/components/post-comment-count" import { PostComments } from "@/components/post-comments" import { PostLikeButton } from "@/components/post-like-button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" -import { getOrderById, getPlayerById, getPostById, listCommentsByPost } from "@/lib/api" +import { getOrderById, getPlayerById, getPostById } from "@/lib/api" import { roleLabels } from "@/lib/constants" export default async function PostDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -16,7 +17,6 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id: const post = getPostById(id) if (!post) notFound() - const comments = listCommentsByPost(id) const linkedOrder = post.linkedOrderId ? getOrderById(post.linkedOrderId) : null const linkedPlayer = linkedOrder ? getPlayerById(linkedOrder.playerId) : null @@ -91,18 +91,15 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id: )}
- - - - {post.commentCount} - + +
- + ) } diff --git a/components/post-comment-count.tsx b/components/post-comment-count.tsx new file mode 100644 index 0000000..0d6e28f --- /dev/null +++ b/components/post-comment-count.tsx @@ -0,0 +1,21 @@ +"use client" + +import { MessageCircle } from "lucide-react" +import { useCommentStore } from "@/store/comments" + +interface PostCommentCountProps { + postId: string +} + +export function PostCommentCount({ postId }: PostCommentCountProps) { + const count = useCommentStore( + (state) => state.comments.filter((comment) => comment.postId === postId).length, + ) + + return ( + + + {count} + + ) +} diff --git a/components/post-comments.tsx b/components/post-comments.tsx index e579d16..4e03d17 100644 --- a/components/post-comments.tsx +++ b/components/post-comments.tsx @@ -1,25 +1,26 @@ "use client" import { Heart } from "lucide-react" -import { useState } from "react" -import { generateId } from "@/lib/id" -import type { Comment } from "@/lib/types" +import { useMemo, useState } from "react" +import { addComment, toggleCommentLike } from "@/lib/api/comments" import { useRequireAuth } from "@/lib/use-require-auth" -import { useAuthStore } from "@/store/auth" +import { useCommentStore } from "@/store/comments" import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" import { Button } from "./ui/button" import { Textarea } from "./ui/textarea" interface PostCommentsProps { - initialComments: Comment[] postId: string } -export function PostComments({ initialComments, postId }: PostCommentsProps) { - const [comments, setComments] = useState(initialComments) +export function PostComments({ postId }: PostCommentsProps) { + const allComments = useCommentStore((state) => state.comments) + const comments = useMemo( + () => allComments.filter((comment) => comment.postId === postId), + [allComments, postId], + ) const [content, setContent] = useState("") const { requireAuth } = useRequireAuth() - const user = useAuthStore((s) => s.user) return (
@@ -32,20 +33,8 @@ export function PostComments({ initialComments, postId }: PostCommentsProps) { requireAuth(() => { const nextContent = content.trim() if (!nextContent) return - if (!user) return - const createdAt = new Date().toISOString() - setComments((prev) => [ - ...prev, - { - id: generateId(`comment-${postId}`), - postId, - author: user, - content: nextContent, - likeCount: 0, - liked: false, - createdAt, - }, - ]) + const decision = addComment(postId, nextContent) + if (!decision.ok) return setContent("") }) }} @@ -85,19 +74,7 @@ export function PostComments({ initialComments, postId }: PostCommentsProps) { className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1 transition-colors" onClick={() => requireAuth(() => { - setComments((prev) => - prev.map((item) => { - if (item.id !== comment.id) return item - const nextLiked = !item.liked - return { - ...item, - liked: nextLiked, - likeCount: nextLiked - ? item.likeCount + 1 - : Math.max(0, item.likeCount - 1), - } - }), - ) + toggleCommentLike(comment.id) }) } > diff --git a/components/post-like-button.tsx b/components/post-like-button.tsx index 2d7a4d4..efd7345 100644 --- a/components/post-like-button.tsx +++ b/components/post-like-button.tsx @@ -1,18 +1,21 @@ "use client" import { Heart } from "lucide-react" -import { useState } from "react" +import { togglePostLike } from "@/lib/api/posts" import { useRequireAuth } from "@/lib/use-require-auth" +import { usePostStore } from "@/store/posts" interface PostLikeButtonProps { - initialLiked: boolean - initialCount: number + postId: string } -export function PostLikeButton({ initialLiked, initialCount }: PostLikeButtonProps) { +export function PostLikeButton({ postId }: PostLikeButtonProps) { const { requireAuth } = useRequireAuth() - const [liked, setLiked] = useState(initialLiked) - const [count, setCount] = useState(initialCount) + const post = usePostStore((state) => state.posts.find((item) => item.id === postId)) + + if (!post) { + return null + } return ( ) } diff --git a/lib/api/comments.ts b/lib/api/comments.ts index e077791..a7dce89 100644 --- a/lib/api/comments.ts +++ b/lib/api/comments.ts @@ -1,9 +1,56 @@ -import { mockComments } from "@/lib/mock" +import { allow, deny } from "@/lib/policy/assert" +import { addNotification } from "@/lib/api/notifications" +import { useAuthStore } from "@/store/auth" +import { useCommentStore } from "@/store/comments" +import { usePostStore } from "@/store/posts" export function listComments() { - return mockComments + return useCommentStore.getState().comments } export function listCommentsByPost(postId: string) { - return mockComments.filter((comment) => comment.postId === postId) + return useCommentStore.getState().comments.filter((comment) => comment.postId === postId) +} + +export function addComment(postId: string, content: string) { + const user = useAuthStore.getState().user + if (!user) { + return deny("AUTH_REQUIRED", "请先登录") + } + + const post = usePostStore.getState().posts.find((item) => item.id === postId) + if (!post) { + return deny("NOT_FOUND", "帖子不存在") + } + + const comment = useCommentStore.getState().addComment(postId, user, content) + if (!comment) { + return deny("VALIDATION_FAILED", "评论内容不能为空") + } + + usePostStore.getState().incrementCommentCount(postId) + + addNotification({ + type: "community", + title: "帖子收到新评论", + content: `《${post.title}》有新的评论`, + link: `/post/${post.id}`, + }) + + return allow() +} + +export function toggleCommentLike(commentId: string) { + const user = useAuthStore.getState().user + if (!user) { + return deny("AUTH_REQUIRED", "请先登录") + } + + const comment = useCommentStore.getState().comments.find((item) => item.id === commentId) + if (!comment) { + return deny("NOT_FOUND", "评论不存在") + } + + useCommentStore.getState().toggleCommentLike(commentId) + return allow() } diff --git a/lib/api/index.ts b/lib/api/index.ts index 017f43e..4dffa3a 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1,13 +1,13 @@ export { getChatSessionById, listChatMessages, listChatSessions } from "./chat" export { requestWithAuth } from "./client" -export { listComments, listCommentsByPost } from "./comments" +export { addComment, listComments, listCommentsByPost, toggleCommentLike } from "./comments" export { getDisputeByOrderId, listDisputes } from "./disputes" export { isFavorited, listFavorites, listFavoritesByUser } from "./favorites" export { getGameById, listGames } from "./games" -export { listNotifications } from "./notifications" +export { addNotification, listNotifications, markNotificationAsRead } from "./notifications" export { getOrderById, listOrders, listOrdersByConsumer } from "./orders" export { getPlayerById, listPlayers, listPlayersByShop } from "./players" -export { getPostById, listPosts, listPostsByAuthor } from "./posts" +export { getPostById, listPosts, listPostsByAuthor, togglePostLike } from "./posts" export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./reviews" export { getServiceById, listServices, listServicesByPlayer } from "./services" export { getShopById, getShopByOwnerId, listShops } from "./shops" diff --git a/lib/api/posts.ts b/lib/api/posts.ts index 7319158..fe1649b 100644 --- a/lib/api/posts.ts +++ b/lib/api/posts.ts @@ -1,3 +1,6 @@ +import { allow, deny } from "@/lib/policy/assert" +import { addNotification } from "@/lib/api/notifications" +import { useAuthStore } from "@/store/auth" import { usePostStore } from "@/store/posts" export function listPosts() { @@ -11,3 +14,30 @@ export function getPostById(postId: string) { export function listPostsByAuthor(userId: string) { return usePostStore.getState().posts.filter((post) => post.author.id === userId) } + +export function togglePostLike(postId: string) { + const user = useAuthStore.getState().user + if (!user) { + return deny("AUTH_REQUIRED", "请先登录") + } + + const post = usePostStore.getState().posts.find((item) => item.id === postId) + if (!post) { + return deny("NOT_FOUND", "帖子不存在") + } + + const shouldNotify = !post.liked + + usePostStore.getState().togglePostLike(postId) + + if (shouldNotify) { + addNotification({ + type: "community", + title: "帖子收到点赞", + content: `《${post.title}》有新的点赞`, + link: `/post/${post.id}`, + }) + } + + return allow() +} diff --git a/store/comments.ts b/store/comments.ts new file mode 100644 index 0000000..72b4af2 --- /dev/null +++ b/store/comments.ts @@ -0,0 +1,47 @@ +import { create } from "zustand" +import { generateId } from "@/lib/id" +import { mockComments } from "@/lib/mock" +import type { Comment, User } from "@/lib/types" + +interface CommentState { + comments: Comment[] + addComment: (postId: string, author: User, content: string) => Comment | null + toggleCommentLike: (commentId: string) => void +} + +export const useCommentStore = create((set) => ({ + comments: mockComments, + addComment: (postId, author, content) => { + const normalizedContent = content.trim() + if (!normalizedContent) return null + + const comment: Comment = { + id: generateId("comment"), + postId, + author, + content: normalizedContent, + likeCount: 0, + liked: false, + createdAt: new Date().toISOString(), + } + + set((state) => ({ + comments: [...state.comments, comment], + })) + + return comment + }, + toggleCommentLike: (commentId) => { + set((state) => ({ + comments: state.comments.map((comment) => { + if (comment.id !== commentId) return comment + const liked = !comment.liked + return { + ...comment, + liked, + likeCount: liked ? comment.likeCount + 1 : Math.max(0, comment.likeCount - 1), + } + }), + })) + }, +})) diff --git a/store/posts.ts b/store/posts.ts index c1357a1..b414383 100644 --- a/store/posts.ts +++ b/store/posts.ts @@ -17,6 +17,8 @@ interface CreatePostInput { interface PostState { posts: Post[] createPost: (input: CreatePostInput) => Post + togglePostLike: (postId: string) => void + incrementCommentCount: (postId: string) => void } export const usePostStore = create((set) => ({ @@ -45,4 +47,22 @@ export const usePostStore = create((set) => ({ return post }, + togglePostLike: (postId) => + set((state) => ({ + posts: state.posts.map((post) => { + if (post.id !== postId) return post + const liked = !post.liked + return { + ...post, + liked, + likeCount: liked ? post.likeCount + 1 : Math.max(0, post.likeCount - 1), + } + }), + })), + incrementCommentCount: (postId) => + set((state) => ({ + posts: state.posts.map((post) => + post.id === postId ? { ...post, commentCount: post.commentCount + 1 } : post, + ), + })), }))