实现博客评论
This commit is contained in:
@@ -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 { }
|
||||||
|
|||||||
22
tone-page-server/src/auth/strategies/OptionalAuthGuard.ts
Normal file
22
tone-page-server/src/auth/strategies/OptionalAuthGuard.ts
Normal 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; // 如果有用户信息,返回用户对象
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
10
tone-page-server/src/blog/dto/create.blogcomment.dto.ts
Normal file
10
tone-page-server/src/blog/dto/create.blogcomment.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user