mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-03 15:56:22 +00:00
✨ feat: 添加评论功能,包括评论输入、评论列表和评论项组件,支持层级深度和私密评论
This commit is contained in:
41
web/src/api/comment.ts
Normal file
41
web/src/api/comment.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import axiosClient from './client'
|
||||
import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment'
|
||||
import type { PaginationParams } from '@/models/common'
|
||||
import { OrderBy } from '@/models/common'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
|
||||
|
||||
export async function createComment(
|
||||
data: CreateCommentRequest,
|
||||
): Promise<BaseResponse<Comment>> {
|
||||
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
data: UpdateCommentRequest,
|
||||
): Promise<BaseResponse<Comment>> {
|
||||
const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${data.id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteComment(id: number): Promise<void> {
|
||||
await axiosClient.delete(`/comment/c/${id}`)
|
||||
}
|
||||
|
||||
export async function listComments(
|
||||
targetType: 'post' | 'page',
|
||||
targetId: number,
|
||||
pagination: PaginationParams = { orderBy: OrderBy.CreatedAt, desc: false, page: 1, size: 10 },
|
||||
depth: number = 1
|
||||
): Promise<BaseResponse<Comment[]>> {
|
||||
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||
params: {
|
||||
targetType,
|
||||
targetId,
|
||||
...pagination,
|
||||
depth
|
||||
}
|
||||
})
|
||||
return res.data
|
||||
}
|
@ -1,14 +1,8 @@
|
||||
import type { Post } from '@/models/post'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import axiosClient from './client'
|
||||
import type { ListPostsParams } from '@/models/post'
|
||||
|
||||
interface ListPostsParams {
|
||||
page?: number
|
||||
size?: number
|
||||
orderBy?: string
|
||||
desc?: boolean
|
||||
keywords?: string
|
||||
}
|
||||
|
||||
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
|
||||
try {
|
||||
|
@ -1,23 +1,8 @@
|
||||
import type { OidcConfig } from '@/models/oidc-config'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import type { User } from '@/models/user'
|
||||
import type { LoginRequest, RegisterRequest, User } from '@/models/user'
|
||||
import axiosClient from './client'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
rememberMe?: boolean // 可以轻松添加新字段
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickname: string
|
||||
email: string
|
||||
verificationCode?: string
|
||||
}
|
||||
|
||||
export async function userLogin(
|
||||
data: LoginRequest,
|
||||
): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||
|
@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square
|
||||
import { RenderMarkdown } from "@/components/common/markdown";
|
||||
import { isMobileByUA } from "@/utils/server/device";
|
||||
import { calculateReadingTime } from "@/utils/common/post";
|
||||
import Link from "next/link";
|
||||
import CommentSection from "@/components/comment";
|
||||
|
||||
function PostMeta({ post }: { post: Post }) {
|
||||
return (
|
||||
@ -75,9 +75,6 @@ async function PostHeader({ post }: { post: Post }) {
|
||||
{post.isOriginal && (
|
||||
<span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded">
|
||||
原创
|
||||
<Link href="./aa" className="text-green-600 hover:underline">
|
||||
查看
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{(post.labels || []).map(label => (
|
||||
@ -141,6 +138,7 @@ async function BlogPost({ post }: { post: Post }) {
|
||||
{/* <ScrollToTop /> */}
|
||||
<PostHeader post={post} />
|
||||
<PostContent post={post} />
|
||||
<CommentSection targetType="post" targetId={post.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
25
web/src/components/comment/comment-input.tsx
Normal file
25
web/src/components/comment/comment-input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import Gravatar from "@/components/common/gravatar"
|
||||
|
||||
export function CommentInput() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex py-4">
|
||||
{/* Avatar */}
|
||||
<div>
|
||||
<Gravatar email="snowykami@outlook.com" className="w-10 h-10 rounded-full" />
|
||||
</div>
|
||||
{/* Input Area */}
|
||||
<div className="flex-1 pl-2">
|
||||
<Textarea placeholder="写下你的评论..." className="w-full p-2 border border-gray-300 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
37
web/src/components/comment/comment-item.tsx
Normal file
37
web/src/components/comment/comment-item.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { Comment } from "@/models/comment";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {useState, useEffect} from "react";
|
||||
|
||||
export function CommentItem(comment: Comment){
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 shadow-sm rounded-lg p-4 mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||
{comment.user.nickname}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{comment.content}</p>
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(comment.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyItem({ reply }: { reply: Comment }) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700 shadow-sm rounded-lg p-4 mb-2 ml-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
{reply.user.nickname}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{reply.content}</p>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(reply.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
41
web/src/components/comment/index.tsx
Normal file
41
web/src/components/comment/index.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { Comment } from "@/models/comment";
|
||||
import { CommentInput } from "@/components/comment/comment-input";
|
||||
import { useEffect, useState } from "react";
|
||||
import { listComments } from "@/api/comment";
|
||||
import { OrderBy } from "@/models/common";
|
||||
|
||||
interface CommentAreaProps {
|
||||
targetType: 'post' | 'page';
|
||||
targetId: number;
|
||||
}
|
||||
|
||||
export default function CommentSection(props: CommentAreaProps) {
|
||||
const { targetType, targetId } = props;
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newComment, setNewComment] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
listComments({ page: 1, size: 10, orderBy: OrderBy.CreatedAt, desc: true }, 1)
|
||||
.then(response => {
|
||||
setComments(response.data);
|
||||
})
|
||||
.catch(err => {
|
||||
setError("加载评论失败,请稍后再试。");
|
||||
console.error("Error loading comments:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [targetType, targetId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>评论区</h2>
|
||||
<CommentInput />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,7 +2,6 @@
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 更安全的类型声明
|
||||
function extractText(node: React.ReactNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
||||
@ -71,7 +70,13 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2
|
||||
opacity-100
|
||||
group-hover:opacity-100
|
||||
sm:opacity-0 sm:group-hover:opacity-100
|
||||
transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 rounded text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600"
|
||||
|
@ -3,6 +3,8 @@ import { MDXRemote, MDXRemoteProps } from "next-mdx-remote-client/rsc";
|
||||
import { Suspense } from "react";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题
|
||||
import "highlight.js/styles/github-dark.css"; // 适用于暗黑模式
|
||||
import "highlight.js/styles/github-dark-dimmed.css"; // 适用于暗黑模式
|
||||
import CodeBlock from "@/components/common/markdown-codeblock";
|
||||
|
||||
export const markdownComponents = {
|
||||
|
18
web/src/components/ui/textarea.tsx
Normal file
18
web/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
28
web/src/models/comment.ts
Normal file
28
web/src/models/comment.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { User } from "./user"
|
||||
|
||||
export interface Comment{
|
||||
id: number
|
||||
targetType: string
|
||||
targetId: number
|
||||
content: string
|
||||
replyId: number
|
||||
depth: number
|
||||
isPrivate: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
targetType: string
|
||||
targetId: number
|
||||
content: string
|
||||
replyId?: number // 可选字段,默认为 null
|
||||
isPrivate?: boolean // 可选字段,默认为 false
|
||||
}
|
||||
|
||||
export interface UpdateCommentRequest {
|
||||
id: number
|
||||
content: string
|
||||
isPrivate?: boolean // 可选字段,默认为 false
|
||||
}
|
15
web/src/models/common.ts
Normal file
15
web/src/models/common.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export interface PaginationParams {
|
||||
orderBy: OrderBy
|
||||
desc: boolean
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export enum OrderBy {
|
||||
CreatedAt = 'created_at',
|
||||
UpdatedAt = 'updated_at',
|
||||
Heat = 'heat',
|
||||
CommentCount = 'comment_count',
|
||||
LikeCount = 'like_count',
|
||||
ViewCount = 'view_count',
|
||||
}
|
@ -18,4 +18,12 @@ export interface Post {
|
||||
heat: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListPostsParams {
|
||||
page?: number
|
||||
size?: number
|
||||
orderBy?: string
|
||||
desc?: boolean
|
||||
keywords?: string
|
||||
}
|
@ -7,4 +7,20 @@ export interface User {
|
||||
gender: string;
|
||||
role: string;
|
||||
language: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
rememberMe?: boolean // 可以轻松添加新字段
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickname: string
|
||||
email: string
|
||||
verificationCode?: string
|
||||
}
|
||||
|
Reference in New Issue
Block a user