实现博客评论

This commit is contained in:
2025-06-07 15:19:43 +08:00
parent 2627c85ec5
commit e646b20456
9 changed files with 123 additions and 23 deletions

View File

@@ -9,6 +9,7 @@ import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { VerificationModule } from 'src/verification/verification.module'; import { VerificationModule } from 'src/verification/verification.module';
import { OptionalAuthGuard } from './strategies/OptionalAuthGuard';
@Module({ @Module({
imports: [ imports: [
@@ -32,11 +33,13 @@ import { VerificationModule } from 'src/verification/verification.module';
providers: [ providers: [
AuthService, AuthService,
JwtStrategy, JwtStrategy,
OptionalAuthGuard,
], ],
exports: [ exports: [
PassportModule, PassportModule,
JwtStrategy, JwtStrategy,
AuthService, AuthService,
OptionalAuthGuard,
] ]
}) })
export class AuthModule { } export class AuthModule { }

View File

@@ -0,0 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Observable, retry } from "rxjs";
@Injectable()
export class OptionalAuthGuard extends AuthGuard('jwt') implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
await super.canActivate(context);
return true;
} catch (error) {
return true;// 如果验证失败,仍然允许访问
}
}
handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
if (err || !user) {
return null; // 如果没有用户信息返回null
}
return user; // 如果有用户信息,返回用户对象
}
}

View File

@@ -1,11 +1,15 @@
import { BadRequestException, Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Request, UseGuards } from '@nestjs/common';
import { BlogService } from './blog.service'; import { BlogService } from './blog.service';
import { OptionalAuthGuard } from 'src/auth/strategies/OptionalAuthGuard';
import { UserService } from 'src/user/user.service';
import { createBlogCommentDto } from './dto/create.blogcomment.dto';
@Controller('blog') @Controller('blog')
export class BlogController { export class BlogController {
constructor( constructor(
private readonly blogService: BlogService, private readonly blogService: BlogService,
private readonly userService: UserService,
) { } ) { }
@Get() @Get()
@@ -42,18 +46,24 @@ export class BlogController {
return await this.blogService.getComments(id); return await this.blogService.getComments(id);
} }
// TODO鉴权该接口允许匿名评论但仍需验证userId合法性 // 该接口允许匿名评论但仍需验证userId合法性
@UseGuards(OptionalAuthGuard)
@Post(':id/comment') @Post(':id/comment')
async createBlogComment( async createBlogComment(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() commentData: { content: string }, @Body() commentData: createBlogCommentDto,
@Request() req,
) { ) {
const { userId } = req.user || {};
const blog = await this.blogService.findById(id); const blog = await this.blogService.findById(id);
if (!blog) throw new BadRequestException('文章不存在'); if (!blog) throw new BadRequestException('文章不存在');
let user = userId ? await this.userService.findOne({ userId }) : null;
const comment = { const comment = {
...commentData, ...commentData,
blogId: id, blogId: id,
user: user,
}; };
return await this.blogService.createComment(comment); return await this.blogService.createComment(comment);

View File

@@ -4,9 +4,11 @@ import { BlogService } from './blog.service';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Blog } from './entity/Blog.entity'; import { Blog } from './entity/Blog.entity';
import { BlogComment } from './entity/BlogComment'; import { BlogComment } from './entity/BlogComment';
import { AuthModule } from 'src/auth/auth.module';
import { UserModule } from 'src/user/user.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Blog, BlogComment])], imports: [TypeOrmModule.forFeature([Blog, BlogComment]), AuthModule, UserModule],
controllers: [BlogController], controllers: [BlogController],
providers: [BlogService], providers: [BlogService],
exports: [BlogService], exports: [BlogService],

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Blog } from './entity/Blog.entity'; import { Blog } from './entity/Blog.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BlogComment } from './entity/BlogComment'; import { BlogComment } from './entity/BlogComment';
import { UserService } from 'src/user/user.service';
@Injectable() @Injectable()
export class BlogService { export class BlogService {

View File

@@ -0,0 +1,10 @@
import { IsOptional, IsString, IsUUID } from "class-validator";
export class createBlogCommentDto {
@IsString({ message: '评论内容不能为空' })
content: string;
@IsOptional()
@IsUUID('4', { message: '父评论ID格式错误' })
parentId?: string;
}

View File

@@ -4,29 +4,69 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { BlogApi } from "@/lib/api"; import { BlogApi } from "@/lib/api";
import { BlogComment } from "@/lib/types/blogComment"; import { BlogComment } from "@/lib/types/blogComment";
import { Send } from "lucide-react"; import { Send, Undo2 } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export function BlogCommentTool({ blogId, onInsertComment }: { blogId: string, onInsertComment: (b: BlogComment) => void }) { interface BlogCommentToolProps {
blogId: string;
onInsertComment: (b: BlogComment) => void;
replayTarget: BlogComment | null;
handleClearReplayTarget: () => void;
}
export function BlogCommentTool({ blogId, onInsertComment, replayTarget, handleClearReplayTarget }: BlogCommentToolProps) {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (replayTarget && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'start'
})
}
}, [replayTarget]);
const submit = async () => { const submit = async () => {
if (comment.trim().length === 0) return; if (comment.trim().length === 0) return;
const res = await BlogApi.createComment(blogId, comment); const res = await BlogApi.createComment(blogId, comment, replayTarget ? replayTarget.id : undefined);
if (res) { if (res) {
toast.success('发布成功'); toast.success('发布成功');
setComment(''); setComment('');
onInsertComment(res); onInsertComment(res);
handleClearReplayTarget();
} }
} }
const getPlaceHolderText = () => {
if (!replayTarget) return '评论';
let replayComment = replayTarget.content.trim();
if (replayComment.length > 8) {
replayComment = replayComment.slice(0, 8) + '...';
}
const replayUser = replayTarget.user ? replayTarget.user.nickname : '匿名';
return `回复 ${replayUser}${replayComment}`;
}
return ( return (
<div className="my-3 flex items-end gap-2"> <div className="my-3 flex items-end gap-2">
<Textarea placeholder="评论" onChange={v => setComment(v.target.value)} value={comment} /> <Textarea
ref={textareaRef}
placeholder={getPlaceHolderText()}
onChange={v => setComment(v.target.value)}
value={comment} />
<Button variant='outline' size='icon' onClick={() => submit()} disabled={comment.trim().length === 0}> <Button variant='outline' size='icon' onClick={() => submit()} disabled={comment.trim().length === 0}>
<Send /> <Send />
</Button> </Button>
{replayTarget && <Button variant='outline' size='icon' onClick={() => handleClearReplayTarget()}>
<Undo2 />
</Button>}
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import useSWR from "swr";
import { BlogCommentTool } from "./BlogCommentTool"; import { BlogCommentTool } from "./BlogCommentTool";
import { BlogApi } from "@/lib/api"; import { BlogApi } from "@/lib/api";
import { BlogComment } from "@/lib/types/blogComment"; import { BlogComment } from "@/lib/types/blogComment";
import { useState } from "react";
export function BlogComments({ blogId }: { blogId: string }) { export function BlogComments({ blogId }: { blogId: string }) {
const { data, isLoading, error, mutate } = useSWR( const { data, isLoading, error, mutate } = useSWR(
@@ -19,34 +20,42 @@ export function BlogComments({ blogId }: { blogId: string }) {
) )
} }
const [replayTarget, setReplayTarget] = useState<BlogComment | null>(null);
return ( return (
data && <div className="" > data && <div className="" >
<h1 className="px-2 border-l-4 border-zinc-300"> {data.length}</h1> <h1 className="px-2 border-l-4 border-zinc-300"> {data.length}</h1>
<BlogCommentTool blogId={blogId} onInsertComment={insertComment} /> <BlogCommentTool
<div className="flex flex-col gap-3"> blogId={blogId}
onInsertComment={insertComment}
replayTarget={replayTarget}
handleClearReplayTarget={() => setReplayTarget(null)}
/>
<div className="flex flex-col">
{ {
data.filter(d => !d.parentId) data.filter(d => !d.parentId)
.map(d => ( .map((d, dIndex) => (
<div key={d.id}> <div key={d.id} className="border-b border-zinc-300 py-2 last:border-none">
<h1 className="text-zinc-500">{d.user ? d.user.nickname : '匿名'}</h1> <h1 className="text-zinc-500">{d.user ? d.user.nickname : '匿名'}</h1>
<div>{d.content}</div> <div className="whitespace-pre-wrap break-all">{d.content}</div>
<div className="text-xs text-zinc-500 flex gap-2"> <div className="text-xs text-zinc-500 flex gap-2">
<p>{new Date(d.createdAt).toLocaleString()}</p> <p>{new Date(d.createdAt).toLocaleString()}</p>
<p></p> <p></p>
<p className="text-zinc-900 cursor-pointer"></p> <p className="text-zinc-900 cursor-pointer" onClick={() => setReplayTarget(d)}></p>
</div> </div>
{ {
data.filter(c => c.parentId === d.id).length > 0 && ( data.filter(c => c.parentId === d.id).length > 0 && (
<div className="flex flex-col gap-3 ml-5 my-1"> <div className="flex flex-col ml-5 my-1">
{ {
data.filter(c => c.parentId === d.id).map(c => ( data.filter(c => c.parentId === d.id).map(c => (
<div key={c.id}> <div key={c.id} className="border-b border-zinc-300 py-1 last:border-none">
<h1 className="text-zinc-500">{c.user ? c.user.nickname : '匿名'}</h1> <h1 className="text-zinc-500">{c.user ? c.user.nickname : '匿名'}</h1>
<div>{c.content}</div> <div className="whitespace-pre-wrap break-all">{c.content}</div>
<p className="text-xs text-zinc-500 flex gap-2"> <div className="text-xs text-zinc-500 flex gap-2">
<p>{new Date().toLocaleString()}</p> <p>{new Date().toLocaleString()}</p>
<p></p> <p></p>
</p> </div>
</div> </div>
)) ))
} }

View File

@@ -1,9 +1,12 @@
import { BlogComment } from "@/lib/types/blogComment"; import { BlogComment } from "@/lib/types/blogComment";
import fetcher from "../fetcher"; import fetcher from "../fetcher";
export async function createComment(blogId: string, content: string) { export async function createComment(blogId: string, content: string, parentId?: string) {
return fetcher<BlogComment>(`/api/blog/${blogId}/comment`, { return fetcher<BlogComment>(`/api/blog/${blogId}/comment`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ content }), body: JSON.stringify({
content,
parentId: parentId || null,
}),
}); });
} }