feat: 添加评论功能,重构评论输入和列表组件,支持多种目标类型,更新国际化文本
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 18s

This commit is contained in:
2025-09-07 23:56:25 +08:00
parent 38584e164e
commit 6237cddc87
16 changed files with 235 additions and 57 deletions

View File

@ -110,5 +110,27 @@ pnpm start
可以通过环境变量或者.env.production文件配置后端API端点 可以通过环境变量或者.env.production文件配置后端API端点
## 开发
### 后端
```bash
# 启动后端服务器
go run ./cmd/server
```
### 前端
```bash
# 进入前端目录
cd web
# 安装依赖
pnpm install
# 启动前端开发服务器
pnpm dev
```
## 环境变量配置 ## 环境变量配置
后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中 后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中

View File

@ -2,9 +2,10 @@ package service
import ( import (
"context" "context"
"github.com/snowykami/neo-blog/pkg/constant"
"strconv" "strconv"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/model"
@ -124,9 +125,12 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
} }
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) { func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
currentUser, _ := ctxutils.GetCurrentUser(ctx) currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID
}
comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth) comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
if err != nil { if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err) return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
} }

View File

@ -3,6 +3,7 @@ import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/co
import type { PaginationParams } from '@/models/common' import type { PaginationParams } from '@/models/common'
import { OrderBy } from '@/models/common' import { OrderBy } from '@/models/common'
import type { BaseResponse } from '@/models/resp' import type { BaseResponse } from '@/models/resp'
import { TargetType } from '@/models/types'
export async function createComment( export async function createComment(
@ -24,7 +25,7 @@ export async function deleteComment(id: number): Promise<void> {
} }
export interface ListCommentsParams { export interface ListCommentsParams {
targetType: 'post' | 'page' targetType: TargetType
targetId: number targetId: number
depth?: number depth?: number
orderBy?: OrderBy orderBy?: OrderBy

View File

@ -5,6 +5,7 @@ import { RenderMarkdown } from "@/components/common/markdown";
import { isMobileByUA } from "@/utils/server/device"; import { isMobileByUA } from "@/utils/server/device";
import { calculateReadingTime } from "@/utils/common/post"; import { calculateReadingTime } from "@/utils/common/post";
import CommentSection from "@/components/comment"; import CommentSection from "@/components/comment";
import { TargetType } from '../../models/types';
function PostMeta({ post }: { post: Post }) { function PostMeta({ post }: { post: Post }) {
return ( return (
@ -94,7 +95,7 @@ async function PostHeader({ post }: { post: Post }) {
} }
async function PostContent({ post }: { post: Post }) { async function PostContent({ post }: { post: Post }) {
const markdownClass = const markdownClass =
"prose prose-lg max-w-none dark:prose-invert " + "prose prose-lg max-w-none dark:prose-invert " +
// h1-h6 // h1-h6
"[&_h1]:scroll-m-20 [&_h1]:text-4xl [&_h1]:font-extrabold [&_h1]:tracking-tight [&_h1]:text-balance [&_h1]:mt-10 [&_h1]:mb-6 " + "[&_h1]:scroll-m-20 [&_h1]:text-4xl [&_h1]:font-extrabold [&_h1]:tracking-tight [&_h1]:text-balance [&_h1]:mt-10 [&_h1]:mb-6 " +
@ -138,7 +139,7 @@ async function BlogPost({ post }: { post: Post }) {
{/* <ScrollToTop /> */} {/* <ScrollToTop /> */}
<PostHeader post={post} /> <PostHeader post={post} />
<PostContent post={post} /> <PostContent post={post} />
<CommentSection targetType="post" targetId={post.id} /> <CommentSection targetType={TargetType.Post} targetId={post.id} />
</div> </div>
); );
} }

View File

@ -1,37 +1,77 @@
"use client"; "use client";
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { getGravatarByUser } from "@/components/common/gravatar" import { getGravatarByUser } from "@/components/common/gravatar"
import { toast } from "sonner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { User } from "@/models/user"; import type { User } from "@/models/user";
import { getLoginUser } from "@/api/user"; import { getLoginUser } from "@/api/user";
import { createComment } from "@/api/comment";
export function CommentInput() { import { CircleUser } from "lucide-react";
const [user, setUser] = useState<User | null>(null); // 假设 User 是你的用户模型 import { useTranslations } from "next-intl";
useEffect(()=>{ import { TargetType } from "@/models/types";
import { useToLogin } from "@/hooks/use-to-login";
import NeedLogin from "../common/need-login";
export function CommentInput(
{ targetId, targetType, onCommentSubmitted }: { targetId: number, targetType: TargetType, onCommentSubmitted: () => void }
) {
const t = useTranslations('Comment')
const toLogin = useToLogin()
const [user, setUser] = useState<User | null>(null);
const [commentContent, setCommentContent] = useState("");
useEffect(() => {
getLoginUser() getLoginUser()
.then(response => { .then(response => {
setUser(response.data); setUser(response.data);
}) })
.catch(error => {
console.error("获取用户信息失败:", error);
});
}, []); }, []);
const handleCommentSubmit = async () => {
if (!user) {
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
return;
}
if (!commentContent.trim()) {
toast.error(t("content_required"));
return;
}
await createComment({
targetType: targetType,
targetId: targetId,
content: commentContent,
}).then(response => {
setCommentContent("");
toast.success(t("comment_success"));
onCommentSubmitted();
}).catch(error => {
toast.error(t("comment_failed") + ": " +
error?.response?.data?.message || error?.message
);
});
};
return ( return (
<div> <div>
<div className="flex py-4"> <div className="flex py-4">
{/* Avatar */} {/* Avatar */}
<div> <div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10">
{user && getGravatarByUser(user)} {user && getGravatarByUser(user)}
{!user && <CircleUser className="w-full h-full" />}
</div> </div>
{/* Input Area */} {/* Input Area */}
<div className="flex-1 pl-2"> <div className="flex-1 pl-2">
<Textarea placeholder="写下你的评论..." className="w-full p-2 border border-gray-300 rounded-md" /> <Textarea
placeholder={t("placeholder")}
className="w-full p-2 border border-gray-300 rounded-md"
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
/>
</div> </div>
</div> </div>
<div className="flex justify-end"> <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 onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
{t("submit")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -2,22 +2,24 @@
import type { Comment } from "@/models/comment"; import type { Comment } from "@/models/comment";
import { CommentInput } from "@/components/comment/comment-input"; import { CommentInput } from "@/components/comment/comment-input";
import { useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { listComments } from "@/api/comment"; import { listComments } from "@/api/comment";
import { OrderBy } from "@/models/common"; import { OrderBy } from "@/models/common";
import { CommentItem } from "./comment-item"; import { CommentItem } from "./comment-item";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { TargetType } from "@/models/types";
import { Skeleton } from "../ui/skeleton";
interface CommentAreaProps { interface CommentAreaProps {
targetType: 'post' | 'page'; targetType: TargetType;
targetId: number; targetId: number;
} }
export default function CommentSection(props: CommentAreaProps) { export default function CommentSection(props: CommentAreaProps) {
const { targetType, targetId } = props; const { targetType, targetId } = props;
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newComment, setNewComment] = useState<string>(""); const [newComment, setNewComment] = useState<string>("");
useEffect(() => { useEffect(() => {
@ -33,30 +35,57 @@ export default function CommentSection(props: CommentAreaProps) {
.then(response => { .then(response => {
setComments(response.data); setComments(response.data);
}) })
.catch(err => {
setError("加载评论失败,请稍后再试。");
console.error("Error loading comments:", err);
})
.finally(() => {
setLoading(false);
});
}, [targetType, targetId]); }, [targetType, targetId]);
const onCommentSubmitted = () => {
// 重新加载评论列表
listComments({
targetType,
targetId,
depth: 0,
orderBy: OrderBy.CreatedAt,
desc: true,
page: 1,
size: 10
})
.then(response => {
setComments(response.data);
})
}
return ( return (
<div> <div>
<Separator className="my-16" /> <Separator className="my-16" />
<div className="font-bold text-2xl"></div> <div className="font-bold text-2xl"></div>
<CommentInput /> <CommentInput targetType={targetType} targetId={targetId} onCommentSubmitted={onCommentSubmitted} />
{loading && <p>...</p>}
{error && <p className="text-red-500">{error}</p>}
<div className="mt-4"> <div className="mt-4">
{comments.map(comment => ( <Suspense fallback={<CommentLoading />}>
<div key={comment.id}> {comments.map(comment => (
<Separator className="my-2" /> <div key={comment.id}>
<CommentItem {...comment} /> <Separator className="my-2" />
</div> <CommentItem {...comment} />
))} </div>
))}
</Suspense>
</div> </div>
</div> </div>
); );
}
function CommentLoading() {
return (
<div className="space-y-6 py-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
);
} }

View File

@ -0,0 +1,7 @@
export default function ImagePlaceholder() {
return (
<div className="w-10 h-10 bg-gray-200 flex items-center justify-center rounded-full">
<img src="/file.svg" alt="Image Placeholder" className="w-6 h-6" />
</div>
);
}

View File

@ -0,0 +1,12 @@
import { useToLogin } from "@/hooks/use-to-login";
export default function NeedLogin(
{ children }: { children?: React.ReactNode }
) {
const toLogin = useToLogin()
return (
<div onClick={toLogin}>
{children}
</div>
);
}

View File

@ -17,11 +17,13 @@ import type { OidcConfig } from "@/models/oidc-config"
import { ListOidcConfigs, userLogin } from "@/api/user" import { ListOidcConfigs, userLogin } from "@/api/user"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
export function LoginForm({ export function LoginForm({
className, className,
...props ...props
}: React.ComponentProps<"div">) { }: React.ComponentProps<"div">) {
const t = useTranslations('Login')
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([]) const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
const [{ username, password }, setCredentials] = useState({ username: '', password: '' }) const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
const router = useRouter() const router = useRouter()
@ -55,9 +57,9 @@ export function LoginForm({
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle> <CardTitle className="text-xl">{t("welcome")}</CardTitle>
<CardDescription> <CardDescription>
Login with Open ID Connect. {t("with_oidc")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -67,15 +69,15 @@ export function LoginForm({
{oidcConfigs.length > 0 && ( {oidcConfigs.length > 0 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{oidcConfigs.map((config, index) => { {oidcConfigs.map((config, index) => {
// 生成唯一的 key
const uniqueKey = config.id || const uniqueKey = config.id ||
config.loginUrl || config.loginUrl ||
`${config.displayName}-${index}` || `${config.displayName}-${index}` ||
`oidc-${index}`; `oidc-${index}`;
return ( return (
<LoginWithOidc <LoginWithOidc
key={uniqueKey} key={uniqueKey}
// 这个REDIRECT_BACK需要前端自己拼接传给后端服务器后端服务器拿来响应给前端另一个页面获取然后改变路由
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))} loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
displayName={config.displayName} displayName={config.displayName}
icon={config.icon} icon={config.icon}
@ -89,7 +91,7 @@ export function LoginForm({
{oidcConfigs.length > 0 && ( {oidcConfigs.length > 0 && (
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"> <div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2"> <span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with {t("or_continue_with_local_account")}
</span> </span>
</div> </div>
)} )}
@ -97,7 +99,7 @@ export function LoginForm({
{/* 邮箱密码登录 */} {/* 邮箱密码登录 */}
<div className="grid gap-6"> <div className="grid gap-6">
<div className="grid gap-3"> <div className="grid gap-3">
<Label htmlFor="email">Email or Username</Label> <Label htmlFor="email">{t("email_or_username")}</Label>
<Input <Input
id="email" id="email"
type="text" type="text"
@ -109,12 +111,12 @@ export function LoginForm({
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex items-center"> <div className="flex items-center">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t("password")}</Label>
<a <a
href="#" href="#"
className="ml-auto text-sm underline-offset-4 hover:underline" className="ml-auto text-sm underline-offset-4 hover:underline"
> >
Forgot your password? {t("forgot_password")}
</a> </a>
</div> </div>
<Input <Input
@ -126,15 +128,15 @@ export function LoginForm({
/> />
</div> </div>
<Button type="submit" className="w-full" onClick={handleLogin}> <Button type="submit" className="w-full" onClick={handleLogin}>
Login {t("login")}
</Button> </Button>
</div> </div>
{/* 注册链接 */} {/* 注册链接 */}
<div className="text-center text-sm"> <div className="text-center text-sm">
Don&apos;t have an account?{" "} {t("no_account")}{" "}
<a href="#" className="underline underline-offset-4"> <a href="#" className="underline underline-offset-4">
Sign up {t("register")}
</a> </a>
</div> </div>
</div> </div>
@ -144,14 +146,14 @@ export function LoginForm({
{/* 服务条款 */} {/* 服务条款 */}
<div className="text-muted-foreground text-center text-xs text-balance"> <div className="text-muted-foreground text-center text-xs text-balance">
By clicking continue, you agree to our{" "} {t("by_logging_in_you_agree_to_our")}{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary"> <a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service {t("terms_of_service")}
</a>{" "} </a>{" "}
and{" "} {t("and")}{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary"> <a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy {t("privacy_policy")}
</a>. </a>
</div> </div>
</div> </div>
) )

View File

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,13 @@
import { useRouter, usePathname } from "next/navigation"
/**
* 用于跳转到登录页并自动带上 redirect_back 参数
* 用法const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
*/
export function useToLogin() {
const router = useRouter()
const pathname = usePathname()
return () => {
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
}
}

View File

@ -1,5 +1,29 @@
{ {
"HomePage": { "HomePage": {
"title": "Hello world!" "title": "Hello world!"
},
"Comment": {
"placeholder": "写下你的评论...",
"submit": "提交",
"login_required": "请先登录后再评论。",
"content_required": "评论内容不能为空。",
"comment_success": "评论提交成功!",
"comment_failed": "评论提交失败"
},
"Login": {
"welcome": "欢迎回来",
"with_oidc": "使用第三方身份提供者",
"or_continue_with_local_account": "或使用用户名和密码",
"email_or_username": "邮箱或用户名",
"password": "密码",
"forgot_password": "忘记密码?",
"no_account": "还没有账号?",
"register": "注册",
"login": "登录",
"by_logging_in_you_agree_to_our": "登录即表示你同意我们的",
"terms_of_service": "服务条款",
"and": "和",
"privacy_policy": "隐私政策",
"login_failed": "登录失败,请检查你的凭据。"
} }
} }

View File

@ -1,8 +1,9 @@
import { TargetType } from "./types"
import type { User } from "./user" import type { User } from "./user"
export interface Comment{ export interface Comment {
id: number id: number
targetType: string targetType: TargetType
targetId: number targetId: number
content: string content: string
replyId: number replyId: number
@ -14,7 +15,7 @@ export interface Comment{
} }
export interface CreateCommentRequest { export interface CreateCommentRequest {
targetType: string targetType: TargetType
targetId: number targetId: number
content: string content: string
replyId?: number // 可选字段,默认为 null replyId?: number // 可选字段,默认为 null

9
web/src/models/types.ts Normal file
View File

@ -0,0 +1,9 @@
// 目标类型枚举
export enum TargetType {
Post = "post",
Page = "page",
Article = "article",
Video = "video",
Image = "image",
Other = "other"
}

View File

@ -13,7 +13,7 @@ export interface User {
export interface LoginRequest { export interface LoginRequest {
username: string username: string
password: string password: string
rememberMe?: boolean // 可以轻松添加新字段 rememberMe?: boolean
captcha?: string captcha?: string
} }