feat: 添加评论功能的客户端信息显示选项,更新相关接口和组件

This commit is contained in:
2025-09-13 16:42:23 +08:00
parent 2d0e1a46e2
commit 4da06b931f
8 changed files with 218 additions and 174 deletions

View File

@ -1,22 +1,23 @@
package dto package dto
type CommentDto struct { type CommentDto struct {
ID uint `json:"id"` ID uint `json:"id"`
TargetID uint `json:"target_id"` TargetID uint `json:"target_id"`
TargetType string `json:"target_type"` // 目标类型,如 "post", "page" TargetType string `json:"target_type"` // 目标类型,如 "post", "page"
Content string `json:"content"` Content string `json:"content"`
ReplyID uint `json:"reply_id"` // 回复的评论ID ReplyID uint `json:"reply_id"` // 回复的评论ID
Depth int `json:"depth"` // 评论的层级深度 Depth int `json:"depth"` // 评论的层级深度
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
User UserDto `json:"user"` // 评论的 User UserDto `json:"user"` // 评论的
ReplyCount uint64 `json:"reply_count"` // 回复数量 ReplyCount uint64 `json:"reply_count"` // 回复数量
LikeCount uint64 `json:"like_count"` // 点赞数量 LikeCount uint64 `json:"like_count"` // 点赞数量
IsLiked bool `json:"is_liked"` // 当前用户是否点赞 IsLiked bool `json:"is_liked"` // 当前用户是否点赞
IsPrivate bool `json:"is_private"` IsPrivate bool `json:"is_private"`
Location string `json:"location"` // 用户位置基于IP Location string `json:"location"` // 用户位置基于IP
OS string `json:"os"` // 用户操作系统基于User-Agent OS string `json:"os"` // 用户操作系统基于User-Agent
Browser string `json:"browser"` // 用户浏览器基于User-Agent Browser string `json:"browser"` // 用户浏览器基于User-Agent
ShowClientInfo bool `json:"show_client_info"`
} }
type CreateCommentReq struct { type CreateCommentReq struct {
@ -31,9 +32,10 @@ type CreateCommentReq struct {
} }
type UpdateCommentReq struct { type UpdateCommentReq struct {
CommentID uint `json:"comment_id" binding:"required"` // 评论ID CommentID uint `json:"comment_id" binding:"required"` // 评论ID
Content string `json:"content" binding:"required"` // 评论内容 Content string `json:"content" binding:"required"` // 评论内容
IsPrivate bool `json:"is_private"` // 是否私密 IsPrivate bool `json:"is_private"` // 是否私密
ShowClientInfo bool `json:"show_client_info"` // 是否显示客户端信息
} }
type GetCommentListReq struct { type GetCommentListReq struct {

View File

@ -127,7 +127,7 @@ func (cr *CommentRepo) UpdateComment(comment *model.Comment) error {
return errs.New(http.StatusBadRequest, "invalid comment ID", nil) return errs.New(http.StatusBadRequest, "invalid comment ID", nil)
} }
if err := GetDB().Select("IsPrivate", "Content").Updates(comment).Error; err != nil { if err := GetDB().Select("IsPrivate", "ShowClientInfo", "Content").Updates(comment).Error; err != nil {
return err return err
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"strconv" "strconv"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/utils" "github.com/snowykami/neo-blog/pkg/utils"
@ -59,6 +60,7 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateComm
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
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 {
@ -71,13 +73,11 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateComm
comment.Content = req.Content comment.Content = req.Content
comment.IsPrivate = req.IsPrivate comment.IsPrivate = req.IsPrivate
comment.ShowClientInfo = req.ShowClientInfo
err = repo.Comment.UpdateComment(comment) err = repo.Comment.UpdateComment(comment)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@ -102,7 +102,6 @@ func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) e
if err := repo.Comment.DeleteComment(commentID); err != nil { if err := repo.Comment.DeleteComment(commentID); err != nil {
return err return err
} }
return nil return nil
} }
@ -120,38 +119,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
if comment.IsPrivate && currentUserID != comment.UserID { if comment.IsPrivate && currentUserID != comment.UserID {
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
isLiked := false commentDto := cs.toGetCommentDto(comment, currentUserID)
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
if !comment.ShowClientInfo {
comment.Location = ""
ua.OS = ""
ua.OSVersion = ""
ua.Browser = ""
ua.BrowserVer = ""
}
commentDto := dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
}
return &commentDto, err return &commentDto, err
} }
@ -167,39 +135,46 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
commentDtos := make([]dto.CommentDto, 0) commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments { for _, comment := range comments {
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID) //replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
isLiked := false commentDto := cs.toGetCommentDto(&comment, currentUserID)
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
commentDto := dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
}
if !comment.ShowClientInfo {
commentDto.Location = ""
commentDto.OS = ""
commentDto.Browser = ""
}
commentDtos = append(commentDtos, commentDto) commentDtos = append(commentDtos, commentDto)
} }
return commentDtos, nil return commentDtos, nil
} }
func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto {
isLiked := false
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
if !comment.ShowClientInfo {
comment.Location = ""
ua.OS = ""
ua.OSVersion = ""
ua.Browser = ""
ua.BrowserVer = ""
}
return dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
ShowClientInfo: comment.ShowClientInfo,
}
}
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:

View File

@ -36,17 +36,21 @@ export async function createComment(
export async function updateComment( export async function updateComment(
{ {
id, content, id,
isPrivate = false content,
isPrivate = false,
showClientInfo = true
}: { }: {
id: number id: number
content: string content: string
isPrivate?: boolean // 可选字段,默认为 false isPrivate?: boolean // 可选字段,默认为 false
showClientInfo?: boolean
} }
): Promise<BaseResponse<Comment>> { ): Promise<BaseResponse<Comment>> {
const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${id}`, { const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${id}`, {
content, content,
isPrivate isPrivate,
showClientInfo,
}) })
return res.data return res.data
} }

View File

@ -17,7 +17,8 @@ export function CommentInput(
initContent = "", initContent = "",
initIsPrivate = false, initIsPrivate = false,
placeholder = "", placeholder = "",
isUpdate = false isUpdate = false,
initShowClientInfo = true
}: { }: {
user: User | null, user: User | null,
onCommentSubmitted: ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => void, onCommentSubmitted: ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => void,
@ -25,6 +26,7 @@ export function CommentInput(
initIsPrivate?: boolean, initIsPrivate?: boolean,
placeholder?: string, placeholder?: string,
isUpdate?: boolean, isUpdate?: boolean,
initShowClientInfo?: boolean
} }
) { ) {
const t = useTranslations('Comment') const t = useTranslations('Comment')
@ -33,7 +35,7 @@ export function CommentInput(
const clickToUserProfile = useToUserProfile(); const clickToUserProfile = useToUserProfile();
const [isPrivate, setIsPrivate] = useState(initIsPrivate); const [isPrivate, setIsPrivate] = useState(initIsPrivate);
const [showClientInfo, setShowClientInfo] = useState(true); const [showClientInfo, setShowClientInfo] = useState(initShowClientInfo);
const [commentContent, setCommentContent] = useState(initContent); const [commentContent, setCommentContent] = useState(initContent);
const handleCommentSubmit = async () => { const handleCommentSubmit = async () => {
@ -51,7 +53,7 @@ export function CommentInput(
toast.error(t("content_required")); toast.error(t("content_required"));
return; return;
} }
if (initContent === commentContent.trim() && initIsPrivate === isPrivate) { if (initContent === commentContent.trim() && initIsPrivate === isPrivate && initShowClientInfo === showClientInfo) {
toast.warning(t("comment_unchanged")); toast.warning(t("comment_unchanged"));
return; return;
} }

View File

@ -10,7 +10,7 @@ import { TargetType } from "@/models/types";
import { toggleLike } from "@/api/like"; import { toggleLike } from "@/api/like";
import { useDoubleConfirm } from "@/hooks/use-double-confirm"; import { useDoubleConfirm } from "@/hooks/use-double-confirm";
import { CommentInput } from "./comment-input"; import { CommentInput } from "./comment-input";
import { createComment, deleteComment, listComments, updateComment } from "@/api/comment"; import { createComment, deleteComment, getComment, listComments, updateComment } from "@/api/comment";
import { OrderBy } from "@/models/common"; import { OrderBy } from "@/models/common";
import { formatDateTime } from "@/utils/common/datetime"; import { formatDateTime } from "@/utils/common/datetime";
@ -41,11 +41,11 @@ export function CommentItem(
const clickToLogin = useToLogin(); const clickToLogin = useToLogin();
const { confirming, onClick, onBlur } = useDoubleConfirm(); const { confirming, onClick, onBlur } = useDoubleConfirm();
const [likeCount, setLikeCount] = useState(comment.likeCount); const [commentState, setCommentState] = useState<Comment>(comment); // 用于更新评论内容
const [liked, setLiked] = useState(comment.isLiked); const [likeCount, setLikeCount] = useState(commentState.likeCount);
const [liked, setLiked] = useState(commentState.isLiked);
const [canClickLike, setCanClickLike] = useState(true); const [canClickLike, setCanClickLike] = useState(true);
const [isPrivate, setIsPrivate] = useState(comment.isPrivate); const [replyCount, setReplyCount] = useState(commentState.replyCount);
const [replyCount, setReplyCount] = useState(comment.replyCount);
const [showReplies, setShowReplies] = useState(false); const [showReplies, setShowReplies] = useState(false);
const [replies, setReplies] = useState<Comment[]>([]); const [replies, setReplies] = useState<Comment[]>([]);
const [repliesLoaded, setRepliesLoaded] = useState(false); const [repliesLoaded, setRepliesLoaded] = useState(false);
@ -74,7 +74,7 @@ export function CommentItem(
setLiked(prev => !prev); setLiked(prev => !prev);
setLikeCount(prev => prev + (likedPrev ? -1 : 1)); setLikeCount(prev => prev + (likedPrev ? -1 : 1));
toggleLike( toggleLike(
{ targetType: TargetType.Comment, targetId: comment.id } { targetType: TargetType.Comment, targetId: commentState.id }
).then(res => { ).then(res => {
toast.success(res.data.status ? t("like_success") : t("unlike_success")); toast.success(res.data.status ? t("like_success") : t("unlike_success"));
setCanClickLike(true); setCanClickLike(true);
@ -90,14 +90,14 @@ export function CommentItem(
const reloadReplies = () => { const reloadReplies = () => {
listComments( listComments(
{ {
targetType: comment.targetType, targetType: commentState.targetType,
targetId: comment.targetId, targetId: commentState.targetId,
depth: comment.depth + 1, depth: commentState.depth + 1,
orderBy: OrderBy.CreatedAt, orderBy: OrderBy.CreatedAt,
desc: false, desc: false,
page: 1, page: 1,
size: 999999, size: 999999,
commentId: comment.id commentId: commentState.id
} }
).then(response => { ).then(response => {
setReplies(response.data.comments); setReplies(response.data.comments);
@ -112,11 +112,13 @@ export function CommentItem(
setShowReplies(!showReplies); setShowReplies(!showReplies);
} }
const onCommentEdit = ({ commentContent: newContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => { const onCommentEdit = ({ commentContent: newContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
updateComment({ id: comment.id, content: newContent, isPrivate }).then(() => { updateComment({ id: commentState.id, content: newContent, isPrivate, showClientInfo }).then(() => {
toast.success(t("edit_success")); toast.success(t("edit_success"));
comment.content = newContent; getComment({ id: commentState.id }).then(response => {
setIsPrivate(isPrivate); setCommentState(response.data);
console.log(response.data);
});
setActiveInputId(null); setActiveInputId(null);
}).catch(error => { }).catch(error => {
toast.error(t("edit_failed") + ": " + error.message); toast.error(t("edit_failed") + ": " + error.message);
@ -125,10 +127,10 @@ export function CommentItem(
const onReply = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => { const onReply = ({ commentContent, isPrivate, showClientInfo }: { commentContent: string, isPrivate: boolean, showClientInfo: boolean }) => {
createComment({ createComment({
targetType: comment.targetType, targetType: commentState.targetType,
targetId: comment.targetId, targetId: commentState.targetId,
content: commentContent, content: commentContent,
replyId: comment.id, replyId: commentState.id,
isPrivate, isPrivate,
showClientInfo showClientInfo
}).then(() => { }).then(() => {
@ -158,24 +160,24 @@ export function CommentItem(
return ( return (
<div> <div>
<div className="flex"> <div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12"> <div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
<GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100} /> <GravatarAvatar className="w-full h-full" url={commentState.user.avatarUrl} email={commentState.user.email} size={100} />
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center"> <div className="flex gap-2 md:gap-4 items-center">
<div onClick={() => clickToUserProfile(comment.user.username)} className="font-bold text-base text-slate-800 dark:text-slate-100 cursor-pointer fade-in-up"> <div onClick={() => clickToUserProfile(commentState.user.username)} className="font-bold text-base text-slate-800 dark:text-slate-100 cursor-pointer fade-in-up">
{comment.user.nickname} {commentState.user.nickname}
</div> </div>
<span className="text-xs">{formatDateTime({ <span className="text-xs">{formatDateTime({
dateTimeString: comment.createdAt, dateTimeString: commentState.createdAt,
locale, locale,
convertShortAgo: true, convertShortAgo: true,
unitI18n: { secondsAgo: commonT("secondsAgo"), minutesAgo: commonT("minutesAgo"), hoursAgo: commonT("hoursAgo"), daysAgo: commonT("daysAgo") } unitI18n: { secondsAgo: commonT("secondsAgo"), minutesAgo: commonT("minutesAgo"), hoursAgo: commonT("hoursAgo"), daysAgo: commonT("daysAgo") }
})}</span> })}</span>
{comment.createdAt !== comment.updatedAt && {commentState.createdAt !== commentState.updatedAt &&
<span className="text-xs">{t("edit_at", { <span className="text-xs">{t("edit_at", {
time: formatDateTime({ time: formatDateTime({
dateTimeString: comment.updatedAt, dateTimeString: commentState.updatedAt,
locale, locale,
convertShortAgo: true, convertShortAgo: true,
unitI18n: { secondsAgo: commonT("secondsAgo"), minutesAgo: commonT("minutesAgo"), hoursAgo: commonT("hoursAgo"), daysAgo: commonT("daysAgo") } unitI18n: { secondsAgo: commonT("secondsAgo"), minutesAgo: commonT("minutesAgo"), hoursAgo: commonT("hoursAgo"), daysAgo: commonT("daysAgo") }
@ -184,105 +186,106 @@ export function CommentItem(
</div> </div>
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in"> <p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
{ {
isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" /> commentState.isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" />
} }
{ {
parentComment && parentComment &&
<>{t("reply")} <button onClick={() => clickToUserProfile(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} {commentState.content}
</p> </p>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 fade-in flex flex-col md:flex-row items-start md:items-center md:justify-between gap-2"> <div className="mt-1 text-xs text-slate-500 dark:text-slate-400 fade-in flex flex-col md:flex-row items-start md:items-center md:justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* 用户地理,浏览器,系统信息 */} {/* 用户地理,浏览器,系统信息 */}
{comment.location && <span title={comment.location} >{comment.location}</span>} {commentState.location && <span title={commentState.location} >{commentState.location}</span>}
{comment.browser && <span title={comment.browser}>{comment.browser}</span>} {commentState.browser && <span title={commentState.browser}>{commentState.browser}</span>}
{comment.os && <span title={comment.os}>{comment.os}</span>} {commentState.os && <span title={commentState.os}>{commentState.os}</span>}
</div> </div>
<div className="flex items-center gap-4 w-full md:w-auto"> <div className="flex items-center gap-4 w-full md:w-auto">
{/* 回复按钮 */} {/* 回复按钮 */}
<button <button
title={t("reply")} title={t("reply")}
onClick={() => { onClick={() => {
if (activeInput?.type === 'reply' && activeInput.id === comment.id) { if (activeInput?.type === 'reply' && activeInput.id === commentState.id) {
setActiveInputId(null); setActiveInputId(null);
} else { } else {
setActiveInputId({ id: comment.id, type: 'reply' }); setActiveInputId({ id: commentState.id, type: 'reply' });
} }
}} }}
className={`flex items-center justify-center px-2 py-1 h-5 className={`flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
rounded ${activeInput?.type === 'reply' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`} rounded ${activeInput?.type === 'reply' && activeInput.id === commentState.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
> >
<Reply className="w-3 h-3" /> <Reply className="w-3 h-3" />
</button> </button>
{/* 点赞按钮 */} {/* 点赞按钮 */}
<button <button
title={t(liked ? "unlike" : "like")} title={t(liked ? "unlike" : "like")}
onClick={handleToggleLike} onClick={handleToggleLike}
className={`flex items-center justify-center px-2 py-1 h-5 gap-1 text-xs rounded className={`flex items-center justify-center px-2 py-1 h-5 gap-1 text-xs rounded
${liked ? 'bg-primary' : 'bg-slate-400 hover:bg-slate-600'} ${liked ? 'bg-primary' : 'bg-slate-400 hover:bg-slate-600'}
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`} text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
> >
<Heart className="w-3 h-3" /> <div>{likeCount}</div> <Heart className="w-3 h-3" /> <div>{likeCount}</div>
</button> </button>
{/* 编辑和删除按钮 仅自己的评论可见 */} {/* 编辑和删除按钮 仅自己的评论可见 */}
{user?.id === comment.user.id && ( {user?.id === commentState.user.id && (
<> <>
<button <button
title={t("edit")} title={t("edit")}
onClick={() => { onClick={() => {
if (activeInput?.type === 'edit' && activeInput.id === comment.id) { if (activeInput?.type === 'edit' && activeInput.id === commentState.id) {
setActiveInputId(null); setActiveInputId(null);
} else { } else {
setActiveInputId({ id: comment.id, type: 'edit' }); setActiveInputId({ id: commentState.id, type: 'edit' });
} }
}} }}
className={` className={`
flex items-center justify-center px-2 py-1 h-5 flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
rounded ${activeInput?.type === 'edit' && activeInput.id === comment.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`} rounded ${activeInput?.type === 'edit' && activeInput.id === commentState.id ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
</button> </button>
<button <button
title={t("delete")} title={t("delete")}
className={`flex items-center justify-center px-2 py-1 h-5 rounded className={`flex items-center justify-center px-2 py-1 h-5 rounded
text-primary-foreground dark:text-white text-xs text-primary-foreground dark:text-white text-xs
${confirming ? 'bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' : 'bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500'} fade-in`} ${confirming ? 'bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' : 'bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500'} fade-in`}
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })} onClick={() => onClick(() => { onCommentDelete({ commentId: commentState.id }); })}
onBlur={onBlur} onBlur={onBlur}
> >
<Trash className="w-3 h-3" /> <Trash className="w-3 h-3" />
{confirming && ( {confirming && (
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span> <span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
)} )}
</button> </button>
</> </>
)} )}
{replyCount > 0 && ( {replyCount > 0 && (
<button onClick={toggleReplies} className="fade-in-up"> <button onClick={toggleReplies} className="fade-in-up">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")} {!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* 这俩输入框一次只能显示一个 */} {/* 这俩输入框一次只能显示一个 */}
{activeInput && activeInput.type === 'reply' && activeInput.id === comment.id && <CommentInput {activeInput && activeInput.type === 'reply' && activeInput.id === commentState.id && <CommentInput
user={user} user={user}
onCommentSubmitted={onReply} onCommentSubmitted={onReply}
initIsPrivate={isPrivate} initIsPrivate={commentState.isPrivate}
placeholder={`${t("reply")} ${comment.user.nickname} :`} placeholder={`${t("reply")} ${commentState.user.nickname} :`}
/>} />}
{activeInput && activeInput.type === 'edit' && activeInput.id === comment.id && <CommentInput {activeInput && activeInput.type === 'edit' && activeInput.id === commentState.id && <CommentInput
user={user} user={user}
initContent={comment.content} initContent={commentState.content}
initIsPrivate={isPrivate} initIsPrivate={commentState.isPrivate}
onCommentSubmitted={onCommentEdit} onCommentSubmitted={onCommentEdit}
isUpdate={true} isUpdate={true}
initShowClientInfo={commentState.showClientInfo}
/>} />}
</div> </div>
@ -294,7 +297,7 @@ export function CommentItem(
key={reply.id} key={reply.id}
user={reply.user} user={reply.user}
comment={reply} comment={reply}
parentComment={comment} parentComment={commentState}
onCommentDelete={onReplyDelete} onCommentDelete={onReplyDelete}
activeInput={activeInput} activeInput={activeInput}
setActiveInputId={setActiveInputId} setActiveInputId={setActiveInputId}

View File

@ -59,6 +59,63 @@ export const markdownComponents = {
{...props} {...props}
/> />
), ),
table: (props: React.ComponentPropsWithoutRef<"table">) => {
const { children, className, ...rest } = props;
return (
<div className="my-4 overflow-auto rounded-md shadow-sm">
<table
className={`min-w-full divide-y divide-gray-200 dark:divide-gray-700 ${className ?? ""}`}
{...rest}
>
{children}
</table>
</div>
);
},
thead: (props: React.ComponentPropsWithoutRef<"thead">) => {
const { children, className, ...rest } = props;
return (
<thead className={`bg-gray-50 dark:bg-gray-800 ${className ?? ""}`} {...rest}>
{children}
</thead>
);
},
tbody: (props: React.ComponentPropsWithoutRef<"tbody">) => {
const { children, className, ...rest } = props;
return (
<tbody className={`bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700 ${className ?? ""}`} {...rest}>
{children}
</tbody>
);
},
tr: (props: React.ComponentPropsWithoutRef<"tr">) => {
const { children, className, ...rest } = props;
return (
<tr className={`odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900 dark:even:bg-gray-800 ${className ?? ""}`} {...rest}>
{children}
</tr>
);
},
th: (props: React.ComponentPropsWithoutRef<"th">) => {
const { children, className, ...rest } = props;
return (
<th
scope="col"
className={`px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider dark:text-gray-400 ${className ?? ""}`}
{...rest}
>
{children}
</th>
);
},
td: (props: React.ComponentPropsWithoutRef<"td">) => {
const { children, className, ...rest } = props;
return (
<td className={`px-3 py-2 align-top text-sm text-gray-700 dark:text-gray-300 ${className ?? ""}`} {...rest}>
{children}
</td>
);
},
}; };
export function RenderMarkdown(props: Omit<MDXRemoteProps, "components">) { export function RenderMarkdown(props: Omit<MDXRemoteProps, "components">) {

View File

@ -18,4 +18,5 @@ export interface Comment {
os: string os: string
browser: string browser: string
location: string location: string
showClientInfo: boolean
} }