mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: 添加用户位置、操作系统和浏览器信息到评论功能
This commit is contained in:
@ -14,6 +14,9 @@ type CommentDto struct {
|
||||
LikeCount uint64 `json:"like_count"` // 点赞数量
|
||||
IsLiked bool `json:"is_liked"` // 当前用户是否点赞
|
||||
IsPrivate bool `json:"is_private"`
|
||||
Location string `json:"location"` // 用户位置,基于IP
|
||||
OS string `json:"os"` // 用户操作系统,基于User-Agent
|
||||
Browser string `json:"browser"` // 用户浏览器,基于User-Agent
|
||||
}
|
||||
|
||||
type CreateCommentReq struct {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/snowykami/neo-blog/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -16,6 +17,12 @@ type Comment struct {
|
||||
IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见
|
||||
RemoteAddr string `gorm:"type:text"` // 远程地址
|
||||
UserAgent string `gorm:"type:text"`
|
||||
Location string `gorm:"type:text"` // 用户位置,基于IP
|
||||
LikeCount uint64
|
||||
CommentCount uint64
|
||||
}
|
||||
|
||||
func (c *Comment) AfterCreate(tx *gorm.DB) (err error) {
|
||||
// 更新评论IP
|
||||
return tx.Model(c).Update("Location", utils.GetLocationString(c.RemoteAddr)).Error
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
"github.com/snowykami/neo-blog/pkg/utils"
|
||||
|
||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
@ -122,6 +123,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
|
||||
UpdatedAt: comment.UpdatedAt.String(),
|
||||
User: comment.User.ToDto(),
|
||||
}
|
||||
// TODO: 返回更多字段
|
||||
|
||||
return &commentDto, err
|
||||
}
|
||||
@ -145,7 +147,7 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
||||
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,
|
||||
@ -160,6 +162,9 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
||||
LikeCount: comment.LikeCount,
|
||||
IsLiked: isLiked,
|
||||
IsPrivate: comment.IsPrivate,
|
||||
OS: ua.OS + " " + ua.OSVersion,
|
||||
Browser: ua.Browser + " " + ua.BrowserVer,
|
||||
Location: comment.Location,
|
||||
}
|
||||
commentDtos = append(commentDtos, commentDto)
|
||||
}
|
||||
|
@ -1 +1,5 @@
|
||||
package utils
|
||||
|
||||
import "resty.dev/v3"
|
||||
|
||||
var client = resty.New()
|
||||
|
@ -1 +1,60 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type IPData struct {
|
||||
IP string `json:"ip"`
|
||||
Dec string `json:"dec"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
Districts string `json:"districts"`
|
||||
IDC string `json:"idc"`
|
||||
ISP string `json:"isp"`
|
||||
Net string `json:"net"`
|
||||
Zipcode string `json:"zipcode"`
|
||||
Areacode string `json:"areacode"`
|
||||
Protocol string `json:"protocol"`
|
||||
Location string `json:"location"`
|
||||
MyIP string `json:"myip"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type IPInfoResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *IPData `json:"data"`
|
||||
}
|
||||
|
||||
func GetIPInfo(ip string) (*IPData, error) {
|
||||
// https://api.mir6.com/api/ip?ip={ip}&type=json
|
||||
ipInfoResponse := &IPInfoResponse{}
|
||||
logrus.Info(fmt.Sprintf("https://api.mir6.com/api/ip?ip=%s&type=json", ip))
|
||||
resp, err := client.R().
|
||||
SetResult(ipInfoResponse).
|
||||
Get(fmt.Sprintf("https://api.mir6.com/api/ip?ip=%s&type=json", ip))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("状态码: %d,响应: %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
return ipInfoResponse.Data, nil
|
||||
}
|
||||
|
||||
func GetLocationString(ip string) string {
|
||||
ipInfo, err := GetIPInfo(ip)
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
return ""
|
||||
}
|
||||
if ipInfo == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s %s", ipInfo.Country, ipInfo.Province, ipInfo.City, ipInfo.ISP)
|
||||
}
|
||||
|
@ -1 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetIPInfo(t *testing.T) {
|
||||
r, err := GetIPInfo("1.1.1.1")
|
||||
t.Log(r, err)
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
"time"
|
||||
)
|
||||
|
||||
type jwtUtils struct{}
|
||||
|
@ -1,8 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"resty.dev/v3"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type oidcUtils struct{}
|
||||
@ -11,61 +10,59 @@ var Oidc = oidcUtils{}
|
||||
|
||||
// RequestToken 请求访问令牌
|
||||
func (u *oidcUtils) RequestToken(tokenEndpoint, clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) {
|
||||
client := resty.New()
|
||||
tokenResp, err := client.R().
|
||||
SetFormData(map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"code": code,
|
||||
"redirect_uri": redirectURI,
|
||||
}).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetResult(&TokenResponse{}).
|
||||
Post(tokenEndpoint)
|
||||
tokenResp, err := client.R().
|
||||
SetFormData(map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"code": code,
|
||||
"redirect_uri": redirectURI,
|
||||
}).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetResult(&TokenResponse{}).
|
||||
Post(tokenEndpoint)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenResp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String())
|
||||
}
|
||||
return tokenResp.Result().(*TokenResponse), nil
|
||||
if tokenResp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("状态码: %d,响应: %s", tokenResp.StatusCode(), tokenResp.String())
|
||||
}
|
||||
return tokenResp.Result().(*TokenResponse), nil
|
||||
}
|
||||
|
||||
// RequestUserInfo 请求用户信息
|
||||
func (u *oidcUtils) RequestUserInfo(userInfoEndpoint, accessToken string) (*UserInfo, error) {
|
||||
client := resty.New()
|
||||
userInfoResp, err := client.R().
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetResult(&UserInfo{}).
|
||||
Get(userInfoEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userInfoResp, err := client.R().
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetResult(&UserInfo{}).
|
||||
Get(userInfoEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userInfoResp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String())
|
||||
}
|
||||
if userInfoResp.StatusCode() != 200 {
|
||||
return nil, fmt.Errorf("状态码: %d,响应: %s", userInfoResp.StatusCode(), userInfoResp.String())
|
||||
}
|
||||
|
||||
return userInfoResp.Result().(*UserInfo), nil
|
||||
return userInfoResp.Result().(*UserInfo), nil
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo 定义用户信息结构
|
||||
type UserInfo struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"` // 可选字段,OIDC提供的用户组信息
|
||||
}
|
||||
|
107
pkg/utils/ua.go
107
pkg/utils/ua.go
@ -1,61 +1,62 @@
|
||||
package utils
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
OS string
|
||||
OSVersion string
|
||||
Browser string
|
||||
BrowserVer string
|
||||
type UAResult struct {
|
||||
OS string
|
||||
OSVersion string
|
||||
Browser string
|
||||
BrowserVer string
|
||||
}
|
||||
|
||||
// ParseUA 解析 UA,返回结构化信息
|
||||
func ParseUA(ua string) Result {
|
||||
r := Result{}
|
||||
func ParseUA(ua string) UAResult {
|
||||
r := UAResult{}
|
||||
// 1. 操作系统 + 版本
|
||||
osRe := []*regexp.Regexp{
|
||||
regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`),
|
||||
regexp.MustCompile(`\(Windows NT ([0-9.]+)\)`),
|
||||
regexp.MustCompile(`\(iPhone;.*OS ([0-9_]+)`),
|
||||
regexp.MustCompile(`\(Android ([0-9.]+)`),
|
||||
regexp.MustCompile(`\(X11;.*Linux `),
|
||||
}
|
||||
for _, re := range osRe {
|
||||
if m := re.FindStringSubmatch(ua); len(m) > 1 {
|
||||
switch {
|
||||
case strings.Contains(m[0], "Macintosh"):
|
||||
r.OS, r.OSVersion = "macOS", strings.Replace(m[1], "_", ".", -1)
|
||||
case strings.Contains(m[0], "Windows NT"):
|
||||
r.OS, r.OSVersion = "Windows", m[1]
|
||||
case strings.Contains(m[0], "iPhone"):
|
||||
r.OS, r.OSVersion = "iOS", strings.Replace(m[1], "_", ".", -1)
|
||||
case strings.Contains(m[0], "Android"):
|
||||
r.OS, r.OSVersion = "Android", m[1]
|
||||
case strings.Contains(m[0], "Linux"):
|
||||
r.OS = "Linux"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 操作系统 + 版本
|
||||
osRe := []*regexp.Regexp{
|
||||
regexp.MustCompile(`\(Macintosh;.*Mac OS X ([0-9_]+)\)`),
|
||||
regexp.MustCompile(`\(Windows NT ([0-9.]+)\)`),
|
||||
regexp.MustCompile(`\(iPhone;.*OS ([0-9_]+)`),
|
||||
regexp.MustCompile(`\(Android ([0-9.]+)`),
|
||||
regexp.MustCompile(`\(X11;.*Linux `),
|
||||
}
|
||||
for _, re := range osRe {
|
||||
if m := re.FindStringSubmatch(ua); len(m) > 1 {
|
||||
switch {
|
||||
case strings.Contains(m[0], "Macintosh"):
|
||||
r.OS, r.OSVersion = "macOS", strings.Replace(m[1], "_", ".", -1)
|
||||
case strings.Contains(m[0], "Windows NT"):
|
||||
r.OS, r.OSVersion = "Windows", m[1]
|
||||
case strings.Contains(m[0], "iPhone"):
|
||||
r.OS, r.OSVersion = "iOS", strings.Replace(m[1], "_", ".", -1)
|
||||
case strings.Contains(m[0], "Android"):
|
||||
r.OS, r.OSVersion = "Android", m[1]
|
||||
case strings.Contains(m[0], "Linux"):
|
||||
r.OS = "Linux"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 浏览器 + 版本(按优先级匹配)
|
||||
browserRe := []struct {
|
||||
re *regexp.Regexp
|
||||
name string
|
||||
}{
|
||||
{regexp.MustCompile(`Edg/([\d.]+)`), "Edge"},
|
||||
{regexp.MustCompile(`Chrome/([\d.]+)`), "Chrome"},
|
||||
{regexp.MustCompile(`Firefox/([\d.]+)`), "Firefox"},
|
||||
{regexp.MustCompile(`Version/([\d.]+).*Safari`), "Safari"},
|
||||
{regexp.MustCompile(`OPR/([\d.]+)`), "Opera"},
|
||||
}
|
||||
for _, b := range browserRe {
|
||||
if m := b.re.FindStringSubmatch(ua); len(m) > 1 {
|
||||
r.Browser, r.BrowserVer = b.name, m[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
// 2. 浏览器 + 版本(按优先级匹配)
|
||||
browserRe := []struct {
|
||||
re *regexp.Regexp
|
||||
name string
|
||||
}{
|
||||
{regexp.MustCompile(`Edg/([\d.]+)`), "Edge"},
|
||||
{regexp.MustCompile(`Chrome/([\d.]+)`), "Chrome"},
|
||||
{regexp.MustCompile(`Firefox/([\d.]+)`), "Firefox"},
|
||||
{regexp.MustCompile(`Version/([\d.]+).*Safari`), "Safari"},
|
||||
{regexp.MustCompile(`OPR/([\d.]+)`), "Opera"},
|
||||
}
|
||||
for _, b := range browserRe {
|
||||
if m := b.re.FindStringSubmatch(ua); len(m) > 1 {
|
||||
r.Browser, r.BrowserVer = b.name, m[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
@ -1 +1,11 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUA(t *testing.T) {
|
||||
ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
|
||||
result := ParseUA(ua)
|
||||
if result.OS != "macOS" || result.OSVersion != "10.15.7" || result.Browser != "Edge" || result.BrowserVer != "140.0.0.0" {
|
||||
t.Errorf("ParseUA failed, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
@ -158,7 +158,7 @@ export function CommentItem(
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(comment.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={comment.user.avatarUrl} email={comment.user.email} size={100} />
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<div className="flex gap-2 md:gap-4 items-center">
|
||||
@ -181,7 +181,6 @@ export function CommentItem(
|
||||
})
|
||||
})}</span>}
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
@ -192,77 +191,84 @@ export function CommentItem(
|
||||
}
|
||||
{comment.content}
|
||||
</p>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
|
||||
|
||||
{/* 点赞按钮 */}
|
||||
<button
|
||||
title={t(liked ? "unlike" : "like")}
|
||||
onClick={handleToggleLike}
|
||||
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'}
|
||||
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
|
||||
>
|
||||
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
|
||||
</button>
|
||||
{/* 回复按钮 */}
|
||||
<button
|
||||
<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">
|
||||
{/* 用户地理,浏览器,系统信息 */}
|
||||
{comment.location && <span title={comment.location} >{comment.location}</span>}
|
||||
{comment.browser && <span title={comment.browser}>{comment.browser}</span>}
|
||||
{comment.os && <span title={comment.os}>{comment.os}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||
{/* 回复按钮 */}
|
||||
<button
|
||||
title={t("reply")}
|
||||
onClick={() => {
|
||||
if (activeInput?.type === 'reply' && activeInput.id === comment.id) {
|
||||
setActiveInputId(null);
|
||||
setActiveInputId(null);
|
||||
} else {
|
||||
setActiveInputId({ id: comment.id, type: 'reply' });
|
||||
setActiveInputId({ id: comment.id, type: 'reply' });
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-center px-2 py-1 h-5
|
||||
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`}>
|
||||
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`}
|
||||
>
|
||||
<Reply className="w-3 h-3" />
|
||||
</button>
|
||||
{/* 编辑和删除按钮 仅自己的评论可见 */}
|
||||
{user?.id === comment.user.id && (
|
||||
</button>
|
||||
{/* 点赞按钮 */}
|
||||
<button
|
||||
title={t(liked ? "unlike" : "like")}
|
||||
onClick={handleToggleLike}
|
||||
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'}
|
||||
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
|
||||
>
|
||||
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
|
||||
</button>
|
||||
|
||||
{/* 编辑和删除按钮 仅自己的评论可见 */}
|
||||
{user?.id === comment.user.id && (
|
||||
<>
|
||||
<button
|
||||
title={t("edit")}
|
||||
onClick={() => {
|
||||
if (activeInput?.type === 'edit' && activeInput.id === comment.id) {
|
||||
setActiveInputId(null);
|
||||
} else {
|
||||
setActiveInputId({ id: comment.id, type: 'edit' });
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
title={t("edit")}
|
||||
onClick={() => {
|
||||
if (activeInput?.type === 'edit' && activeInput.id === comment.id) {
|
||||
setActiveInputId(null);
|
||||
} else {
|
||||
setActiveInputId({ id: comment.id, type: 'edit' });
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
flex items-center justify-center px-2 py-1 h-5
|
||||
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`}
|
||||
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t("delete")}
|
||||
className={`flex items-center justify-center px-2 py-1 h-5 rounded
|
||||
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`}
|
||||
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })}
|
||||
onBlur={onBlur}
|
||||
title={t("delete")}
|
||||
className={`flex items-center justify-center px-2 py-1 h-5 rounded
|
||||
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`}
|
||||
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
|
||||
<Trash className="w-3 h-3" />
|
||||
{confirming && (
|
||||
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
|
||||
)}
|
||||
<Trash className="w-3 h-3" />
|
||||
{confirming && (
|
||||
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{replyCount > 0 &&
|
||||
{replyCount > 0 && (
|
||||
<button onClick={toggleReplies} className="fade-in-up">
|
||||
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 这俩输入框一次只能显示一个 */}
|
||||
{activeInput && activeInput.type === 'reply' && activeInput.id === comment.id && <CommentInput
|
||||
user={user}
|
||||
|
@ -15,4 +15,7 @@ export interface Comment {
|
||||
replyCount: number
|
||||
likeCount: number
|
||||
isLiked: boolean
|
||||
os: string
|
||||
browser: string
|
||||
location: string
|
||||
}
|
||||
|
Reference in New Issue
Block a user