feat: 优化评论功能,添加登录提示和国际化支持,重构相关组件
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 10s

This commit is contained in:
2025-09-09 23:21:08 +08:00
parent cb3f602663
commit 4fb39110ad
7 changed files with 278 additions and 229 deletions

View File

@ -1,117 +1,117 @@
package model package model
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"gorm.io/gorm" "gorm.io/gorm"
"resty.dev/v3" "resty.dev/v3"
) )
type OidcConfig struct { type OidcConfig struct {
gorm.Model gorm.Model
Name string `gorm:"uniqueIndex"` // OIDC配置名称唯一 Name string `gorm:"uniqueIndex"` // OIDC配置名称唯一
ClientID string // 客户端ID ClientID string // 客户端ID
ClientSecret string // 客户端密钥 ClientSecret string // 客户端密钥
DisplayName string // 显示名称,例如:轻雪通行证 DisplayName string // 显示名称,例如:轻雪通行证
Icon string // 图标url为空则使用内置默认图标 Icon string // 图标url为空则使用内置默认图标
OidcDiscoveryUrl string // OpenID自动发现URL例如 https://pass.liteyuki.org/.well-known/openid-configuration OidcDiscoveryUrl string // OpenID自动发现URL例如 https://pass.liteyuki.org/.well-known/openid-configuration
Enabled bool `gorm:"default:true"` // 是否启用 Enabled bool `gorm:"default:true"` // 是否启用
Type string `gorm:"oauth2"` // OIDC类型默认为oauth2,也可以为misskey Type string `gorm:"oauth2"` // OIDC类型默认为oauth2,也可以为misskey
// 以下字段为自动获取字段,每次更新配置时自动填充 // 以下字段为自动获取字段,每次更新配置时自动填充
Issuer string Issuer string
AuthorizationEndpoint string AuthorizationEndpoint string
TokenEndpoint string TokenEndpoint string
UserInfoEndpoint string UserInfoEndpoint string
JwksUri string JwksUri string
} }
type oidcDiscoveryResp struct { type oidcDiscoveryResp struct {
Issuer string `json:"issuer" validate:"required"` Issuer string `json:"issuer" validate:"required"`
AuthorizationEndpoint string `json:"authorization_endpoint" validate:"required"` AuthorizationEndpoint string `json:"authorization_endpoint" validate:"required"`
TokenEndpoint string `json:"token_endpoint" validate:"required"` TokenEndpoint string `json:"token_endpoint" validate:"required"`
UserInfoEndpoint string `json:"userinfo_endpoint" validate:"required"` UserInfoEndpoint string `json:"userinfo_endpoint" validate:"required"`
JwksUri string `json:"jwks_uri" validate:"required"` JwksUri string `json:"jwks_uri" validate:"required"`
// 可选字段 // 可选字段
RegistrationEndpoint string `json:"registration_endpoint,omitempty"` RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"` ScopesSupported []string `json:"scopes_supported,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported,omitempty"` ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
GrantTypesSupported []string `json:"grant_types_supported,omitempty"` GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`
IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"` IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
ClaimsSupported []string `json:"claims_supported,omitempty"` ClaimsSupported []string `json:"claims_supported,omitempty"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
} }
func updateOidcConfigFromUrl(url string, typ string) (*oidcDiscoveryResp, error) { func updateOidcConfigFromUrl(url string, typ string) (*oidcDiscoveryResp, error) {
client := resty.New() client := resty.New()
client.SetTimeout(10 * time.Second) // 设置超时时间 client.SetTimeout(10 * time.Second) // 设置超时时间
var discovery oidcDiscoveryResp var discovery oidcDiscoveryResp
resp, err := client.R(). resp, err := client.R().
SetHeader("Accept", "application/json"). SetHeader("Accept", "application/json").
SetResult(&discovery). SetResult(&discovery).
Get(url) Get(url)
if err != nil { if err != nil {
return nil, fmt.Errorf("请求OIDC发现端点失败: %w", err) return nil, fmt.Errorf("请求OIDC发现端点失败: %w", err)
} }
if resp.StatusCode() != 200 { if resp.StatusCode() != 200 {
return nil, fmt.Errorf("请求OIDC发现端点失败状态码: %d", resp.StatusCode()) return nil, fmt.Errorf("请求OIDC发现端点失败状态码: %d", resp.StatusCode())
} }
// 验证必要字段 // 验证必要字段
if typ == "misskey" { if typ == "misskey" {
discovery.UserInfoEndpoint = discovery.Issuer + "/api/users/me" // Misskey的用户信息端点 discovery.UserInfoEndpoint = discovery.Issuer + "/api/users/me" // Misskey的用户信息端点
discovery.JwksUri = discovery.Issuer + "/api/jwks" discovery.JwksUri = discovery.Issuer + "/api/jwks"
} }
fmt.Println(discovery) fmt.Println(discovery)
if discovery.Issuer == "" || if discovery.Issuer == "" ||
discovery.AuthorizationEndpoint == "" || discovery.AuthorizationEndpoint == "" ||
discovery.TokenEndpoint == "" || discovery.TokenEndpoint == "" ||
discovery.UserInfoEndpoint == "" || discovery.UserInfoEndpoint == "" ||
discovery.JwksUri == "" { discovery.JwksUri == "" {
return nil, fmt.Errorf("OIDC发现端点响应缺少必要字段") return nil, fmt.Errorf("OIDC发现端点响应缺少必要字段")
} }
return &discovery, nil return &discovery, nil
} }
func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) { func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) {
// 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息 // 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息
if tx.Statement.Changed("OidcDiscoveryUrl") || o.ID == 0 { if tx.Statement.Changed("OidcDiscoveryUrl") || o.ID == 0 {
logrus.Infof("Updating OIDC config for %s, OidcDiscoveryUrl: %s", o.Name, o.OidcDiscoveryUrl) logrus.Infof("Updating OIDC config for %s, OidcDiscoveryUrl: %s", o.Name, o.OidcDiscoveryUrl)
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl, o.Type) discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl, o.Type)
if err != nil { if err != nil {
logrus.Error("Updating OIDC config failed: ", err) logrus.Error("Updating OIDC config failed: ", err)
return fmt.Errorf("updating OIDC config failed: %w", err) return fmt.Errorf("updating OIDC config failed: %w", err)
} }
o.Issuer = discoveryResp.Issuer o.Issuer = discoveryResp.Issuer
o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint
o.TokenEndpoint = discoveryResp.TokenEndpoint o.TokenEndpoint = discoveryResp.TokenEndpoint
o.UserInfoEndpoint = discoveryResp.UserInfoEndpoint o.UserInfoEndpoint = discoveryResp.UserInfoEndpoint
o.JwksUri = discoveryResp.JwksUri o.JwksUri = discoveryResp.JwksUri
} }
return nil return nil
} }
// ToUserDto 返回给用户侧 // ToUserDto 返回给用户侧
func (o *OidcConfig) ToUserDto() *dto.UserOidcConfigDto { func (o *OidcConfig) ToUserDto() *dto.UserOidcConfigDto {
return &dto.UserOidcConfigDto{ return &dto.UserOidcConfigDto{
Name: o.Name, Name: o.Name,
DisplayName: o.DisplayName, DisplayName: o.DisplayName,
Icon: o.Icon, Icon: o.Icon,
} }
} }
// ToAdminDto 返回给管理员侧 // ToAdminDto 返回给管理员侧
func (o *OidcConfig) ToAdminDto() *dto.AdminOidcConfigDto { func (o *OidcConfig) ToAdminDto() *dto.AdminOidcConfigDto {
return &dto.AdminOidcConfigDto{ return &dto.AdminOidcConfigDto{
ID: o.ID, ID: o.ID,
Name: o.Name, Name: o.Name,
ClientID: o.ClientID, ClientID: o.ClientID,
ClientSecret: o.ClientSecret, ClientSecret: o.ClientSecret,
DisplayName: o.DisplayName, DisplayName: o.DisplayName,
Icon: o.Icon, Icon: o.Icon,
OidcDiscoveryUrl: o.OidcDiscoveryUrl, OidcDiscoveryUrl: o.OidcDiscoveryUrl,
Enabled: o.Enabled, Enabled: o.Enabled,
} }
} }

View File

@ -214,9 +214,33 @@ func (cr *CommentRepo) ListComments(currentUserID, targetID, commentID uint, tar
return items, nil return items, nil
} }
func (cr *CommentRepo) CountReplyComments(commentID uint) (int64, error) { func (cr *CommentRepo) CountReplyComments(currentUserID, commentID uint) (int64, error) {
var count int64 var count int64
if err := GetDB().Model(&model.Comment{}).Where("reply_id = ?", commentID).Count(&count).Error; err != nil { var masterID uint
// 根据commentID查询所属对象的用户ID
comment, err := cr.GetComment(strconv.Itoa(int(commentID)))
if err != nil {
return 0, err
}
if comment.TargetType == constant.TargetTypePost {
post, err := Post.GetPostByID(strconv.Itoa(int(comment.TargetID)))
if err != nil {
return 0, err
}
masterID = post.UserID
} else {
// 如果不是文章类型,可以根据需要添加其他类型的处理逻辑
return 0, errs.New(http.StatusBadRequest, "unsupported target type for counting replies", nil)
}
query := GetDB().Model(&model.Comment{}).Where("reply_id = ?", commentID)
if currentUserID > 0 {
query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID)
} else {
query = query.Where("is_private = ?", false)
}
if err := query.Count(&count).Error; err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil

View File

@ -1,177 +1,177 @@
package service package service
import ( import (
"context" "context"
"strconv" "strconv"
"github.com/snowykami/neo-blog/pkg/constant" "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"
"github.com/snowykami/neo-blog/internal/repo" "github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
) )
type CommentService struct{} type CommentService struct{}
func NewCommentService() *CommentService { func NewCommentService() *CommentService {
return &CommentService{} return &CommentService{}
} }
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) error { func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
if err != nil { if err != nil {
return errs.New(errs.ErrBadRequest.Code, "target not found", err) return errs.New(errs.ErrBadRequest.Code, "target not found", err)
} }
return errs.ErrBadRequest return errs.ErrBadRequest
} }
comment := &model.Comment{ comment := &model.Comment{
Content: req.Content, Content: req.Content,
ReplyID: req.ReplyID, ReplyID: req.ReplyID,
TargetID: req.TargetID, TargetID: req.TargetID,
TargetType: req.TargetType, TargetType: req.TargetType,
UserID: currentUser.ID, UserID: currentUser.ID,
IsPrivate: req.IsPrivate, IsPrivate: req.IsPrivate,
} }
err := repo.Comment.CreateComment(comment) err := repo.Comment.CreateComment(comment)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error { func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID))) comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
if err != nil { if err != nil {
return err return err
} }
if currentUser.ID != comment.UserID { if currentUser.ID != comment.UserID {
return errs.ErrForbidden return errs.ErrForbidden
} }
comment.Content = req.Content comment.Content = req.Content
comment.IsPrivate = req.IsPrivate comment.IsPrivate = req.IsPrivate
err = repo.Comment.UpdateComment(comment) err = repo.Comment.UpdateComment(comment)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error { func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
if commentID == "" { if commentID == "" {
return errs.ErrBadRequest return errs.ErrBadRequest
} }
comment, err := repo.Comment.GetComment(commentID) comment, err := repo.Comment.GetComment(commentID)
if err != nil { if err != nil {
return errs.New(errs.ErrNotFound.Code, "comment not found", err) return errs.New(errs.ErrNotFound.Code, "comment not found", err)
} }
if comment.UserID != currentUser.ID { if comment.UserID != currentUser.ID {
return errs.ErrForbidden return errs.ErrForbidden
} }
if err := repo.Comment.DeleteComment(commentID); err != nil { if err := repo.Comment.DeleteComment(commentID); err != nil {
return err return err
} }
return nil return nil
} }
func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) { func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) {
comment, err := repo.Comment.GetComment(commentID) comment, err := repo.Comment.GetComment(commentID)
if err != nil { if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err) return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
} }
commentDto := dto.CommentDto{ commentDto := dto.CommentDto{
ID: comment.ID, ID: comment.ID,
TargetID: comment.TargetID, TargetID: comment.TargetID,
TargetType: comment.TargetType, TargetType: comment.TargetType,
Content: comment.Content, Content: comment.Content,
ReplyID: comment.ReplyID, ReplyID: comment.ReplyID,
Depth: comment.Depth, Depth: comment.Depth,
CreatedAt: comment.CreatedAt.String(), CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
User: comment.User.ToDto(), User: comment.User.ToDto(),
} }
return &commentDto, err return &commentDto, err
} }
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) {
currentUserID := uint(0) currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID currentUserID = currentUser.ID
} }
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth) comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, 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)
} }
commentDtos := make([]dto.CommentDto, 0) commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments { for _, comment := range comments {
replyCount, _ := repo.Comment.CountReplyComments(comment.ID) replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
isLiked := false isLiked := false
if currentUserID != 0 { if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment) isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
} }
commentDto := dto.CommentDto{ commentDto := dto.CommentDto{
ID: comment.ID, ID: comment.ID,
Content: comment.Content, Content: comment.Content,
TargetID: comment.TargetID, TargetID: comment.TargetID,
TargetType: comment.TargetType, TargetType: comment.TargetType,
ReplyID: comment.ReplyID, ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(), CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth, Depth: comment.Depth,
User: comment.User.ToDto(), User: comment.User.ToDto(),
ReplyCount: replyCount, ReplyCount: replyCount,
LikeCount: comment.LikeCount, LikeCount: comment.LikeCount,
IsLiked: isLiked, IsLiked: isLiked,
IsPrivate: comment.IsPrivate, IsPrivate: comment.IsPrivate,
} }
commentDtos = append(commentDtos, commentDto) commentDtos = append(commentDtos, commentDto)
} }
return commentDtos, nil return commentDtos, nil
} }
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) { func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
switch targetType { switch targetType {
case constant.TargetTypePost: case constant.TargetTypePost:
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil { if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err) return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
} }
default: default:
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil) return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
} }
return true, nil return true, nil
} }

View File

@ -27,15 +27,22 @@ export function CommentInput(
} }
) { ) {
const t = useTranslations('Comment') const t = useTranslations('Comment')
const handleToLogin = useToLogin() const commonT = useTranslations('Common')
const toUserProfile = useToUserProfile(); const clickToLogin = useToLogin()
const clickToUserProfile = useToUserProfile();
const [isPrivate, setIsPrivate] = useState(initIsPrivate); const [isPrivate, setIsPrivate] = useState(initIsPrivate);
const [commentContent, setCommentContent] = useState(initContent); const [commentContent, setCommentContent] = useState(initContent);
const handleCommentSubmit = async () => { const handleCommentSubmit = async () => {
if (!user) { if (!user) {
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>); // 通知
toast.error(t("login_required"), {
action: {
label: commonT("login"),
onClick: clickToLogin,
},
})
return; return;
} }
if (!commentContent.trim()) { if (!commentContent.trim()) {
@ -49,13 +56,13 @@ export function CommentInput(
return ( return (
<div className="fade-in-up"> <div className="fade-in-up">
<div className="flex py-4 fade-in"> <div className="flex py-4 fade-in">
<div onClick={user ? () => toUserProfile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in"> <div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
{user ? getGravatarByUser(user) : null} {user ? getGravatarByUser(user) : null}
{!user && <CircleUser className="w-full h-full fade-in" />} {!user && <CircleUser className="w-full h-full fade-in" />}
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">
<Textarea <Textarea
placeholder={user?t("placeholder"):t("login_required")} placeholder={user ? t("placeholder") : t("login_required", { loginButton: "登录" })}
className="w-full p-2 border border-gray-300 rounded-md fade-in-up" className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
value={commentContent} value={commentContent}
onChange={(e) => setCommentContent(e.target.value)} onChange={(e) => setCommentContent(e.target.value)}

View File

@ -1,4 +1,4 @@
import { useToUserProfile } from "@/hooks/use-route"; import { useToLogin, useToUserProfile } from "@/hooks/use-route";
import { User } from "@/models/user"; import { User } from "@/models/user";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
@ -29,7 +29,9 @@ export function CommentItem(
} }
) { ) {
const t = useTranslations("Comment") const t = useTranslations("Comment")
const toUserProfile = useToUserProfile(); const commonT = useTranslations('Common')
const clickToUserProfile = useToUserProfile();
const clickToLogin = useToLogin();
const { confirming, onClick, onBlur } = useDoubleConfirm(); const { confirming, onClick, onBlur } = useDoubleConfirm();
const [likeCount, setLikeCount] = useState(comment.likeCount); const [likeCount, setLikeCount] = useState(comment.likeCount);
@ -43,6 +45,19 @@ export function CommentItem(
const [showEditInput, setShowEditInput] = useState(false); const [showEditInput, setShowEditInput] = useState(false);
const handleToggleLike = () => { const handleToggleLike = () => {
if (!user) {
toast.error(t("login_required"), {
action: <div className="flex justify-end">
<button
onClick={clickToLogin}
className="ml-0 text-left bg-red-400 text-white dark:text-black px-3 py-1 rounded font-semibold hover:bg-red-600 transition-colors"
>
{commonT("login")}
</button>
</div>,
});
return;
}
toggleLike( toggleLike(
{ targetType: TargetType.Comment, targetId: comment.id } { targetType: TargetType.Comment, targetId: comment.id }
).then(res => { ).then(res => {
@ -133,7 +148,7 @@ export function CommentItem(
} }
{ {
parentComment && parentComment &&
<>{t("reply")} <button onClick={() => toUserProfile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </> <>{t("reply")} <button onClick={() => clickToUserProfile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </>
} }
{comment.content} {comment.content}
</p> </p>

View File

@ -19,7 +19,7 @@
"like": "点赞", "like": "点赞",
"like_failed": "点赞失败", "like_failed": "点赞失败",
"like_success": "点赞成功", "like_success": "点赞成功",
"login_required": "请先登录后再评论。", "login_required": "请先登录后再操作",
"placeholder": "写你的评论...", "placeholder": "写你的评论...",
"private": "私密评论", "private": "私密评论",
"reply": "回复", "reply": "回复",
@ -28,6 +28,9 @@
"unlike_success": "已取消点赞", "unlike_success": "已取消点赞",
"update": "更新" "update": "更新"
}, },
"Common":{
"login": "登录"
},
"Login": { "Login": {
"welcome": "欢迎回来", "welcome": "欢迎回来",
"with_oidc": "使用第三方身份提供者", "with_oidc": "使用第三方身份提供者",