初步完成评论

This commit is contained in:
2025-06-07 03:21:27 +08:00
parent c872b55083
commit 3821ef6657
12 changed files with 208 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { BlogService } from './blog.service';
@Controller('blog')
@@ -31,4 +31,31 @@ export class BlogController {
content: blogContent,
};
}
@Get(':id/comments')
async getBlogComments(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
) {
const blog = await this.blogService.findById(id);
if (!blog) throw new BadRequestException('文章不存在');
return await this.blogService.getComments(id);
}
// TODO鉴权该接口允许匿名评论但仍需验证userId合法性
@Post(':id/comment')
async createBlogComment(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() commentData: { content: string },
) {
const blog = await this.blogService.findById(id);
if (!blog) throw new BadRequestException('文章不存在');
const comment = {
...commentData,
blogId: id,
};
return await this.blogService.createComment(comment);
}
}

View File

@@ -3,9 +3,10 @@ import { BlogController } from './blog.controller';
import { BlogService } from './blog.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Blog } from './entity/Blog.entity';
import { BlogComment } from './entity/BlogComment';
@Module({
imports: [TypeOrmModule.forFeature([Blog])],
imports: [TypeOrmModule.forFeature([Blog, BlogComment])],
controllers: [BlogController],
providers: [BlogService],
exports: [BlogService],

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Blog } from './entity/Blog.entity';
import { Repository } from 'typeorm';
import { BlogComment } from './entity/BlogComment';
@Injectable()
export class BlogService {
@@ -9,6 +10,8 @@ export class BlogService {
constructor(
@InjectRepository(Blog)
private readonly blogRepository: Repository<Blog>,
@InjectRepository(BlogComment)
private readonly blogCommentRepository: Repository<BlogComment>,
) { }
async list() {
@@ -43,4 +46,19 @@ export class BlogService {
async incrementViewCount(id: string) {
await this.blogRepository.increment({ id }, 'viewCount', 1);
}
async getComments(id: string) {
return this.blogCommentRepository.find({
where: { blogId: id },
relations: ['user'],
order: {
createdAt: 'DESC',
}
});
}
async createComment(comment: Partial<BlogComment>) {
const newComment = this.blogCommentRepository.create(comment);
return this.blogCommentRepository.save(newComment);
}
}

View File

@@ -1,4 +1,5 @@
import { Column, CreateDateColumn, DeleteDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { BlogComment } from "./BlogComment";
@Entity()
export class Blog {
@@ -27,4 +28,8 @@ export class Blog {
deletedAt: Date;
// 权限关系 TODO
// 关系
@OneToMany(() => BlogComment, blog => blog.id)
comments: BlogComment[];
}

View File

@@ -0,0 +1,33 @@
import { User } from "src/user/entities/user.entity";
import { Column, CreateDateColumn, DeleteDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class BlogComment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
content: string;
// @Column()
// ip: string;
// @Column()
// address: string;
@CreateDateColumn({ precision: 3 })
createdAt: Date;
@DeleteDateColumn({ precision: 3, nullable: true })
deletedAt: Date;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'userId' })
user: User | null;
@Column({ type: 'uuid', nullable: true })
blogId: string | null;
@Column({ type: 'uuid', nullable: true })
parentId: string | null;
}

View File

@@ -0,0 +1,30 @@
'use client';
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { BlogApi } from "@/lib/api";
import { Send } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export function BlogCommentTool({ blogId }: { blogId: string }) {
const [comment, setComment] = useState('');
const submit = async () => {
const res = await BlogApi.createComment(blogId, comment);
if (res) {
toast.success('发布成功');
setComment('');
// 提交界面刷新
}
}
return (
<div className="my-3 flex items-end gap-2">
<Textarea placeholder="评论" onChange={v => setComment(v.target.value)} value={comment} />
<Button variant='outline' size='icon' onClick={() => submit()}>
<Send />
</Button>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import useSWR from "swr";
import { BlogCommentTool } from "./BlogCommentTool";
import { BlogApi } from "@/lib/api";
export function BlogComments({ blogId }: { blogId: string }) {
const { data, isLoading, error } = useSWR(
`/api/blog/${blogId}/comments`,
() => BlogApi.getComments(blogId),
)
return (
data && <div className="" >
<h1 className="px-2 border-l-4 border-zinc-300"> {data.length}</h1>
<BlogCommentTool blogId={blogId} />
<div className="flex flex-col gap-3">
{
data.filter(d => !d.parentId)
.map(d => (
<div key={d.id}>
<h1 className="text-zinc-500">{d.user ? d.user : '匿名'}</h1>
<div>{d.content}</div>
<div className="text-xs text-zinc-500 flex gap-2">
<p>{new Date(d.createdAt).toLocaleString()}</p>
<p></p>
<p className="text-zinc-900 cursor-pointer"></p>
</div>
{
data.filter(c => c.parentId === d.id).length > 0 && (
<div className="flex flex-col gap-3 ml-5 my-1">
{
data.filter(c => c.parentId === d.id).map(c => (
<div key={c.id}>
<h1 className="text-zinc-500">{c.user ? c.user : '匿名'}</h1>
<div>{c.content}</div>
<p className="text-xs text-zinc-500 flex gap-2">
<p>{new Date().toLocaleString()}</p>
<p></p>
</p>
</div>
))
}
</div>
)
}
</div>
))
}
</div >
</div>
)
}

View File

@@ -12,6 +12,7 @@ import { PhotoProvider, PhotoView } from 'react-photo-view';
import 'react-photo-view/dist/react-photo-view.css';
import rehypeRaw from 'rehype-raw'
import { Skeleton } from "@/components/ui/skeleton";
import { BlogComments } from "./components/BlogComments";
export default function Blog() {
const params = useParams();
@@ -74,6 +75,13 @@ export default function Blog() {
/>
</>
)}
{data && (
<>
<div className="border my-5"></div>
<BlogComments blogId={data.id} />
</>
)}
</div>
</div>
)

View File

@@ -0,0 +1,15 @@
import fetcher from "../fetcher";
export async function createComment(blogId: string, content: string) {
return fetcher<{
blogId: string;
content: string;
createdAt: string
deletedAt: null; // 原则上能看到就是null
id: string;
parentId: string | null;
}>(`/api/blog/${blogId}/comment`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}

View File

@@ -0,0 +1,13 @@
import fetcher from "../fetcher";
export async function getComments(blogId: string) {
return fetcher<{
blogId: string;
content: string;
createdAt: string;
deletedAt: string | null;// 原则上能看到就是null
id: string;
parentId: string | null; // 如果是回复则有parentId
user: null;// TODO需要完善
}[]>(`/api/blog/${blogId}/comments`, { method: 'GET' });
}

View File

@@ -1,2 +1,4 @@
export * from './list';
export * from './get';
export * from './getComments';
export * from './createComment';

View File

@@ -4,3 +4,4 @@ export * as AdminApi from './admin/index';
export * as ResourceApi from './resource/index';
export * as BlogApi from './blog/index';
export * as UserApi from './user/index';
export * as OssApi from './oss/index';