mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 02:56:22 +00:00
Refactor comment system:
- Update comment API to handle private comments and improve request structure. - Remove unused CSS animations and components related to comments. - Implement new comment input and item components with enhanced functionality including editing and private comment options. - Integrate user profile navigation and improve user experience with better feedback on actions (like, delete, edit). - Update localization for new features and ensure consistency in comment handling. - Introduce checkbox for private comments in the comment input.
This commit is contained in:
@ -1,9 +1,10 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
|
||||||
"resty.dev/v3"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
"resty.dev/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type captchaUtils struct{}
|
type captchaUtils struct{}
|
||||||
@ -11,83 +12,83 @@ type captchaUtils struct{}
|
|||||||
var Captcha = captchaUtils{}
|
var Captcha = captchaUtils{}
|
||||||
|
|
||||||
type CaptchaConfig struct {
|
type CaptchaConfig struct {
|
||||||
Type string
|
Type string
|
||||||
SiteSecret string // Site secret key for the captcha service
|
SiteSecret string // Site secret key for the captcha service
|
||||||
SecretKey string // Secret key for the captcha service
|
SecretKey string // Secret key for the captcha service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
|
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
|
||||||
return &CaptchaConfig{
|
return &CaptchaConfig{
|
||||||
Type: Env.Get("CAPTCHA_TYPE", "disable"),
|
Type: Env.Get("CAPTCHA_TYPE", "disable"),
|
||||||
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""),
|
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""),
|
||||||
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""),
|
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyCaptcha 根据提供的配置和令牌验证验证码
|
// VerifyCaptcha 根据提供的配置和令牌验证验证码
|
||||||
func (c *captchaUtils) VerifyCaptcha(captchaConfig *CaptchaConfig, captchaToken string) (bool, error) {
|
func (c *captchaUtils) VerifyCaptcha(captchaConfig *CaptchaConfig, captchaToken string) (bool, error) {
|
||||||
restyClient := resty.New()
|
restyClient := resty.New()
|
||||||
switch captchaConfig.Type {
|
switch captchaConfig.Type {
|
||||||
case constant.CaptchaTypeDisable:
|
case constant.CaptchaTypeDisable:
|
||||||
return true, nil
|
return true, nil
|
||||||
case constant.CaptchaTypeHCaptcha:
|
case constant.CaptchaTypeHCaptcha:
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
resp, err := restyClient.R().
|
resp, err := restyClient.R().
|
||||||
SetFormData(map[string]string{
|
SetFormData(map[string]string{
|
||||||
"secret": captchaConfig.SecretKey,
|
"secret": captchaConfig.SecretKey,
|
||||||
"response": captchaToken,
|
"response": captchaToken,
|
||||||
}).SetResult(&result).Post("https://hcaptcha.com/siteverify")
|
}).SetResult(&result).Post("https://hcaptcha.com/siteverify")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if resp.IsError() {
|
if resp.IsError() {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
fmt.Printf("%#v\n", result)
|
fmt.Printf("%#v\n", result)
|
||||||
if success, ok := result["success"].(bool); ok && success {
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
return true, nil
|
return true, nil
|
||||||
} else {
|
} else {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
case constant.CaptchaTypeTurnstile:
|
case constant.CaptchaTypeTurnstile:
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
resp, err := restyClient.R().
|
resp, err := restyClient.R().
|
||||||
SetFormData(map[string]string{
|
SetFormData(map[string]string{
|
||||||
"secret": captchaConfig.SecretKey,
|
"secret": captchaConfig.SecretKey,
|
||||||
"response": captchaToken,
|
"response": captchaToken,
|
||||||
}).SetResult(&result).Post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
}).SetResult(&result).Post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if resp.IsError() {
|
if resp.IsError() {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
fmt.Printf("%#v\n", result)
|
fmt.Printf("%#v\n", result)
|
||||||
if success, ok := result["success"].(bool); ok && success {
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
return true, nil
|
return true, nil
|
||||||
} else {
|
} else {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
case constant.CaptchaTypeReCaptcha:
|
case constant.CaptchaTypeReCaptcha:
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
resp, err := restyClient.R().
|
resp, err := restyClient.R().
|
||||||
SetFormData(map[string]string{
|
SetFormData(map[string]string{
|
||||||
"secret": captchaConfig.SecretKey,
|
"secret": captchaConfig.SecretKey,
|
||||||
"response": captchaToken,
|
"response": captchaToken,
|
||||||
}).SetResult(&result).Post("https://www.google.com/recaptcha/api/siteverify")
|
}).SetResult(&result).Post("https://www.google.com/recaptcha/api/siteverify")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if resp.IsError() {
|
if resp.IsError() {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
fmt.Printf("%#v\n", result)
|
fmt.Printf("%#v\n", result)
|
||||||
if success, ok := result["success"].(bool); ok && success {
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
return true, nil
|
return true, nil
|
||||||
} else {
|
} else {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("invalid captcha type: %s", captchaConfig.Type)
|
return false, fmt.Errorf("invalid captcha type: %s", captchaConfig.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
60
web/pnpm-lock.yaml
generated
60
web/pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@radix-ui/react-checkbox':
|
||||||
|
specifier: ^1.3.3
|
||||||
|
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.14
|
specifier: ^1.1.14
|
||||||
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@ -436,6 +439,22 @@ packages:
|
|||||||
'@radix-ui/primitive@1.1.2':
|
'@radix-ui/primitive@1.1.2':
|
||||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
||||||
|
|
||||||
|
'@radix-ui/primitive@1.1.3':
|
||||||
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-checkbox@1.3.3':
|
||||||
|
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7':
|
'@radix-ui/react-collection@1.1.7':
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -585,6 +604,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-presence@1.1.5':
|
||||||
|
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-primitive@2.1.3':
|
'@radix-ui/react-primitive@2.1.3':
|
||||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -3052,6 +3084,24 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.2': {}
|
'@radix-ui/primitive@1.1.2': {}
|
||||||
|
|
||||||
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
|
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
@ -3192,6 +3242,16 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import axiosClient from './client'
|
import axiosClient from './client'
|
||||||
import { UpdateCommentRequest, Comment } from '@/models/comment'
|
import { Comment } from '@/models/comment'
|
||||||
import { PaginationParams } from '@/models/common'
|
import { 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'
|
||||||
@ -32,13 +32,23 @@ export async function createComment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: UpdateCommentRequest,
|
{
|
||||||
|
id, content,
|
||||||
|
isPrivate = false
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
content: string
|
||||||
|
isPrivate?: boolean // 可选字段,默认为 false
|
||||||
|
}
|
||||||
): Promise<BaseResponse<Comment>> {
|
): Promise<BaseResponse<Comment>> {
|
||||||
const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${data.id}`, data)
|
const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${id}`, {
|
||||||
|
content,
|
||||||
|
isPrivate
|
||||||
|
})
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteComment(id: number): Promise<void> {
|
export async function deleteComment({ id }: { id: number }): Promise<void> {
|
||||||
await axiosClient.delete(`/comment/c/${id}`)
|
await axiosClient.delete(`/comment/c/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
/* 评论区原生动画:淡入、上移 */
|
|
||||||
.fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in-up {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(16px);
|
|
||||||
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}/* 评论区原生动画:淡入、上移 */
|
|
||||||
.fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in-up {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(16px);
|
|
||||||
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar"
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { User } from "@/models/user";
|
|
||||||
import { getLoginUser } from "@/api/user";
|
|
||||||
import { createComment } from "@/api/comment";
|
|
||||||
|
|
||||||
import { CircleUser } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { TargetType } from "@/models/types";
|
|
||||||
import { useToLogin } from "@/hooks/use-route";
|
|
||||||
import NeedLogin from "../common/need-login";
|
|
||||||
|
|
||||||
|
|
||||||
import "./comment-animations.css";
|
|
||||||
|
|
||||||
export function CommentInput(
|
|
||||||
{ targetId, targetType, replyId, onCommentSubmitted }: { targetId: number, targetType: TargetType, replyId: number | null, onCommentSubmitted: () => void }
|
|
||||||
) {
|
|
||||||
|
|
||||||
const t = useTranslations('Comment')
|
|
||||||
const toLogin = useToLogin()
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [commentContent, setCommentContent] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getLoginUser()
|
|
||||||
.then(response => {
|
|
||||||
setUser(response.data);
|
|
||||||
})
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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,
|
|
||||||
replyId: replyId,
|
|
||||||
isPrivate: false,
|
|
||||||
}).then(response => {
|
|
||||||
setCommentContent("");
|
|
||||||
toast.success(t("comment_success"));
|
|
||||||
onCommentSubmitted();
|
|
||||||
}).catch(error => {
|
|
||||||
toast.error(t("comment_failed") + ": " +
|
|
||||||
error?.response?.data?.message || error?.message
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fade-in-up">
|
|
||||||
<div className="flex py-4 fade-in">
|
|
||||||
{/* Avatar */}
|
|
||||||
<div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
|
||||||
{user && getGravatarByUser(user)}
|
|
||||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
|
||||||
</div>
|
|
||||||
{/* Input Area */}
|
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
|
||||||
<Textarea
|
|
||||||
placeholder={t("placeholder")}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
|
|
||||||
value={commentContent}
|
|
||||||
onChange={(e) => setCommentContent(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end fade-in-up">
|
|
||||||
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
|
|
||||||
{t("submit")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Comment } from "@/models/comment";
|
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Reply, Trash } from "lucide-react";
|
|
||||||
import { toggleLike } from "@/api/like";
|
|
||||||
import { TargetType } from "@/models/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { CommentInput } from "./comment-input";
|
|
||||||
import { deleteComment, listComments } from "@/api/comment";
|
|
||||||
import { OrderBy } from "@/models/common";
|
|
||||||
import { getLoginUser } from "@/api/user";
|
|
||||||
import type { User } from "@/models/user";
|
|
||||||
import Link from "next/link";
|
|
||||||
import "./comment-animations.css";
|
|
||||||
|
|
||||||
export function CommentItem({comment, parentComment, onCommentDelete}:{comment: Comment, parentComment: Comment | null, onCommentDelete: () => void}) {
|
|
||||||
const t = useTranslations("Comment")
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [liked, setLiked] = useState(comment.isLiked);
|
|
||||||
const [likeCount, setLikeCount] = useState(comment.likeCount);
|
|
||||||
const [replyCount, setReplyCount] = useState(comment.replyCount);
|
|
||||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
|
||||||
const [showReplies, setShowReplies] = useState(false);
|
|
||||||
// 二次确认删除
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getLoginUser()
|
|
||||||
.then(response => {
|
|
||||||
setUser(response.data);
|
|
||||||
})
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleLike = () => {
|
|
||||||
toggleLike({ targetType: TargetType.Comment, targetId: comment.id })
|
|
||||||
.then(res => {
|
|
||||||
setLiked(res.data.status);
|
|
||||||
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
|
|
||||||
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
toast.error(t("like_failed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteComment = (id: number) => {
|
|
||||||
deleteComment(id)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(t("delete_success"));
|
|
||||||
onCommentDelete();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
toast.error(t("delete_failed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const onReplySubmitted = () => {
|
|
||||||
setReplyCount(replyCount + 1);
|
|
||||||
setShowReplyInput(false);
|
|
||||||
setShowReplies(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex fade-in-up">
|
|
||||||
<div className="fade-in">
|
|
||||||
{getGravatarByUser(comment.user)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
|
||||||
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
|
|
||||||
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
|
|
||||||
{parentComment && <>{t("reply")} <Link href={`/u/${parentComment.user.username}`} className="text-primary">{parentComment?.user.nickname}</Link>: </>}
|
|
||||||
{comment.content}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
|
|
||||||
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleToggleLike}
|
|
||||||
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded
|
|
||||||
${liked ? 'bg-primary text-primary-foreground dark:text-white' : 'bg-slate-400 hover:bg-slate-600'}
|
|
||||||
dark:hover:bg-slate-500 fade-in`}
|
|
||||||
>
|
|
||||||
👍 {likeCount}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowReplyInput(!showReplyInput)}
|
|
||||||
className="flex items-center justify-center px-2 py-1 h-5
|
|
||||||
text-primary-foreground dark:text-white text-xs
|
|
||||||
rounded bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up">
|
|
||||||
<Reply className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
{comment.user.id === user?.id && (
|
|
||||||
deleteConfirm ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteComment(comment.id)}
|
|
||||||
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 fade-in"
|
|
||||||
onBlur={() => setDeleteConfirm(false)}
|
|
||||||
title={t("confirm_delete")}
|
|
||||||
>
|
|
||||||
{t("confirm_delete")}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(true)}
|
|
||||||
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-slate-400 hover:bg-red-600 dark:hover:bg-red-500 fade-in"
|
|
||||||
title={t("delete")}
|
|
||||||
>
|
|
||||||
<Trash className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{replyCount > 0 &&
|
|
||||||
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
|
|
||||||
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{showReplyInput && <CommentInput targetId={comment.targetId} targetType={comment.targetType} replyId={comment.id} onCommentSubmitted={onReplySubmitted} />}
|
|
||||||
{showReplies && replyCount > 0 && <RepliesList parentComment={comment} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一个评论的回复区域组件
|
|
||||||
function RepliesList({ parentComment }: { parentComment: Comment }) {
|
|
||||||
const t = useTranslations("Comment")
|
|
||||||
const [replies, setReplies] = useState<Comment[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
listComments({
|
|
||||||
targetType: parentComment.targetType,
|
|
||||||
targetId: parentComment.targetId,
|
|
||||||
commentId: parentComment.id,
|
|
||||||
depth: parentComment.depth + 1,
|
|
||||||
orderBy: OrderBy.CreatedAt,
|
|
||||||
desc: false,
|
|
||||||
page: 1,
|
|
||||||
size: 9999,
|
|
||||||
}).then(res => {
|
|
||||||
setReplies(res.data);
|
|
||||||
}).catch(error => {
|
|
||||||
toast.error(t("load_replies_failed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}, [parentComment])
|
|
||||||
|
|
||||||
const onCommentDelete = () => {
|
|
||||||
listComments({
|
|
||||||
targetType: parentComment.targetType,
|
|
||||||
targetId: parentComment.targetId,
|
|
||||||
commentId: parentComment.id,
|
|
||||||
depth: parentComment.depth + 1,
|
|
||||||
orderBy: OrderBy.CreatedAt,
|
|
||||||
desc: false,
|
|
||||||
page: 1,
|
|
||||||
size: 9999,
|
|
||||||
}).then(res => {
|
|
||||||
setReplies(res.data);
|
|
||||||
}).catch(error => {
|
|
||||||
toast.error(t("load_replies_failed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 border-l border-slate-300 pl-4">
|
|
||||||
{replies.map(reply => (
|
|
||||||
<div key={reply.id} className="mb-4">
|
|
||||||
<CommentItem comment={reply} parentComment={parentComment} onCommentDelete={onCommentDelete} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Comment } from "@/models/comment";
|
|
||||||
import { CommentInput } from "@/components/comment/comment-input";
|
|
||||||
import "./comment-animations.css";
|
|
||||||
import { Suspense, useEffect, useState } from "react";
|
|
||||||
import { listComments } from "@/api/comment";
|
|
||||||
import { OrderBy } from "@/models/common";
|
|
||||||
import { CommentItem } from "./comment-item";
|
|
||||||
import { Separator } from "../ui/separator";
|
|
||||||
import { TargetType } from "@/models/types";
|
|
||||||
import { Skeleton } from "../ui/skeleton";
|
|
||||||
|
|
||||||
interface CommentAreaProps {
|
|
||||||
targetType: TargetType;
|
|
||||||
targetId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function CommentSection(props: CommentAreaProps) {
|
|
||||||
const { targetType, targetId } = props;
|
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
|
||||||
const [newComment, setNewComment] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listComments({
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
depth: 0,
|
|
||||||
orderBy: OrderBy.CreatedAt,
|
|
||||||
desc: true,
|
|
||||||
page: 1,
|
|
||||||
size: 10
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
setComments(response.data);
|
|
||||||
})
|
|
||||||
}, [targetType, targetId]);
|
|
||||||
|
|
||||||
const onCommentsChange = () => {
|
|
||||||
// 重新加载评论列表
|
|
||||||
listComments({
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
depth: 0,
|
|
||||||
orderBy: OrderBy.CreatedAt,
|
|
||||||
desc: true,
|
|
||||||
page: 1,
|
|
||||||
size: 10
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
setComments(response.data);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 支持分页加载更多评论
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Separator className="my-16" />
|
|
||||||
<div className="font-bold text-2xl">评论</div>
|
|
||||||
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentsChange} />
|
|
||||||
<div className="mt-4">
|
|
||||||
<Suspense fallback={<CommentLoading />}>
|
|
||||||
{comments.map((comment, idx) => (
|
|
||||||
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<CommentItem comment={comment} parentComment={null} onCommentDelete={onCommentsChange} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function CommentLoading() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 py-8">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="flex gap-3 fade-in-up" style={{ animationDelay: `${i * 80}ms` }}>
|
|
||||||
<Skeleton className="w-10 h-10 rounded-full fade-in" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-1/4 fade-in" />
|
|
||||||
<Skeleton className="h-4 w-3/4 fade-in" />
|
|
||||||
<Skeleton className="h-4 w-2/3 fade-in" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
79
web/src/components/neo-comment/comment-input.tsx
Normal file
79
web/src/components/neo-comment/comment-input.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
||||||
|
import { User } from "@/models/user";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import NeedLogin from "@/components/common/need-login";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||||
|
import { CircleUser } from "lucide-react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export function CommentInput(
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
onCommentSubmitted,
|
||||||
|
initContent = "",
|
||||||
|
initIsPrivate = false,
|
||||||
|
isUpdate = false
|
||||||
|
}: {
|
||||||
|
user: User | null,
|
||||||
|
onCommentSubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
|
||||||
|
initContent?: string,
|
||||||
|
initIsPrivate?: boolean,
|
||||||
|
isUpdate?: boolean,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const t = useTranslations('Comment')
|
||||||
|
const handleToLogin = useToLogin()
|
||||||
|
const toUserProfile = useToUserProfile();
|
||||||
|
|
||||||
|
const [isPrivate, setIsPrivate] = useState(initIsPrivate);
|
||||||
|
const [commentContent, setCommentContent] = useState(initContent);
|
||||||
|
|
||||||
|
const handleCommentSubmit = async () => {
|
||||||
|
if (!user) {
|
||||||
|
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!commentContent.trim()) {
|
||||||
|
toast.error(t("content_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCommentSubmitted({ commentContent, isPrivate });
|
||||||
|
setCommentContent("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in-up">
|
||||||
|
<div className="flex py-4 fade-in">
|
||||||
|
<div onClick={user ? () => toUserProfile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
||||||
|
{user ? getGravatarByUser(user) : null}
|
||||||
|
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
|
<Textarea
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
|
||||||
|
value={commentContent}
|
||||||
|
onChange={(e) => setCommentContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end fade-in-up space-x-4 items-center">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={isPrivate}
|
||||||
|
onCheckedChange={checked => setIsPrivate(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label>{t("private")}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
|
||||||
|
{isUpdate ? t("update") : t("submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
224
web/src/components/neo-comment/comment-item.tsx
Normal file
224
web/src/components/neo-comment/comment-item.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { useToUserProfile } from "@/hooks/use-route";
|
||||||
|
import { User } from "@/models/user";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||||
|
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
|
||||||
|
import { Comment } from "@/models/comment";
|
||||||
|
import { TargetType } from "@/models/types";
|
||||||
|
import { toggleLike } from "@/api/like";
|
||||||
|
import { useDoubleConfirm } from "@/hooks/use-double-confirm";
|
||||||
|
import { CommentInput } from "./comment-input";
|
||||||
|
import { createComment, deleteComment, listComments, updateComment } from "@/api/comment";
|
||||||
|
import { OrderBy } from "@/models/common";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
export function CommentItem(
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
comment,
|
||||||
|
parentComment,
|
||||||
|
onCommentDelete,
|
||||||
|
}: {
|
||||||
|
user: User | null,
|
||||||
|
comment: Comment,
|
||||||
|
parentComment: Comment | null,
|
||||||
|
onCommentDelete: ({ commentId }: { commentId: number }) => void,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const t = useTranslations("Comment")
|
||||||
|
const toUserProfile = useToUserProfile();
|
||||||
|
const { confirming, onClick, onBlur } = useDoubleConfirm();
|
||||||
|
|
||||||
|
const [likeCount, setLikeCount] = useState(comment.likeCount);
|
||||||
|
const [liked, setLiked] = useState(comment.isLiked);
|
||||||
|
const [replyCount, setReplyCount] = useState(comment.replyCount);
|
||||||
|
const [showReplies, setShowReplies] = useState(false);
|
||||||
|
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||||
|
const [replies, setReplies] = useState<Comment[]>([]);
|
||||||
|
const [repliesLoaded, setRepliesLoaded] = useState(false);
|
||||||
|
const [showEditInput, setShowEditInput] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleLike = () => {
|
||||||
|
toggleLike(
|
||||||
|
{ targetType: TargetType.Comment, targetId: comment.id }
|
||||||
|
).then(res => {
|
||||||
|
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
|
||||||
|
setLiked(res.data.status);
|
||||||
|
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("like_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadReplies = () => {
|
||||||
|
listComments(
|
||||||
|
{
|
||||||
|
targetType: comment.targetType,
|
||||||
|
targetId: comment.targetId,
|
||||||
|
depth: comment.depth + 1,
|
||||||
|
orderBy: OrderBy.CreatedAt,
|
||||||
|
desc: false,
|
||||||
|
page: 1,
|
||||||
|
size: config.commentsPerPage,
|
||||||
|
commentId: comment.id
|
||||||
|
}
|
||||||
|
).then(response => {
|
||||||
|
setReplies(response.data);
|
||||||
|
setRepliesLoaded(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReplies = () => {
|
||||||
|
if (!showReplies && !repliesLoaded) {
|
||||||
|
reloadReplies();
|
||||||
|
}
|
||||||
|
setShowReplies(!showReplies);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCommentEdit = ({ commentContent: newContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
|
||||||
|
updateComment({ id: comment.id, content: newContent, isPrivate }).then(() => {
|
||||||
|
toast.success(t("edit_success"));
|
||||||
|
comment.content = newContent;
|
||||||
|
comment.isPrivate = isPrivate;
|
||||||
|
setShowEditInput(false);
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("edit_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReply = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
|
||||||
|
createComment({
|
||||||
|
targetType: comment.targetType,
|
||||||
|
targetId: comment.targetId,
|
||||||
|
content: commentContent,
|
||||||
|
replyId: comment.id,
|
||||||
|
isPrivate,
|
||||||
|
}).then(() => {
|
||||||
|
toast.success(t("comment_success"));
|
||||||
|
reloadReplies();
|
||||||
|
setShowReplies(true);
|
||||||
|
setShowReplyInput(false);
|
||||||
|
setReplyCount(replyCount + 1);
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("comment_failed") + ": " +
|
||||||
|
error?.response?.data?.message || error?.message
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReplyDelete = ({ commentId: replyId }: { commentId: number }) => {
|
||||||
|
deleteComment({ id: replyId }).then(() => {
|
||||||
|
toast.success(t("delete_success"));
|
||||||
|
setReplyCount(replyCount - 1);
|
||||||
|
setReplies(replies.filter(r => r.id !== replyId));
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("delete_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<div className="fade-in">
|
||||||
|
{getGravatarByUser(comment.user)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
|
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
|
||||||
|
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
|
||||||
|
{
|
||||||
|
comment.isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" />
|
||||||
|
}
|
||||||
|
{
|
||||||
|
parentComment &&
|
||||||
|
<>{t("reply")} <button onClick={() => toUserProfile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </>
|
||||||
|
}
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
|
||||||
|
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
|
||||||
|
{/* 点赞按钮 */}
|
||||||
|
<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
|
||||||
|
title={t("reply")}
|
||||||
|
onClick={() => { setShowReplyInput(!showReplyInput); setShowEditInput(false); }}
|
||||||
|
className={`flex items-center justify-center px-2 py-1 h-5
|
||||||
|
text-primary-foreground dark:text-white text-xs
|
||||||
|
rounded ${showReplyInput ? "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
|
||||||
|
title={t("edit")}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center px-2 py-1 h-5
|
||||||
|
text-primary-foreground dark:text-white text-xs
|
||||||
|
rounded ${showEditInput ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
|
||||||
|
onClick={() => { setShowEditInput(!showEditInput); setShowReplyInput(false); }}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Trash className="w-3 h-3" />
|
||||||
|
{confirming && (
|
||||||
|
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replyCount > 0 &&
|
||||||
|
<button onClick={toggleReplies} className="fade-in-up">
|
||||||
|
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{/* 这俩输入框一次只能显示一个 */}
|
||||||
|
{showReplyInput && !showEditInput && <CommentInput
|
||||||
|
user={user}
|
||||||
|
onCommentSubmitted={onReply}
|
||||||
|
/>}
|
||||||
|
{showEditInput && !showReplyInput && <CommentInput
|
||||||
|
user={user}
|
||||||
|
initContent={comment.content}
|
||||||
|
initIsPrivate={comment.isPrivate}
|
||||||
|
onCommentSubmitted={onCommentEdit}
|
||||||
|
isUpdate={true}
|
||||||
|
/>}
|
||||||
|
{showReplies && replies.length > 0 && (
|
||||||
|
<div className="mt-4 pl-4 border-l border-slate-300 dark:border-slate-600 space-y-4">
|
||||||
|
{replies.map((reply) => (
|
||||||
|
<CommentItem
|
||||||
|
key={reply.id}
|
||||||
|
user={reply.user}
|
||||||
|
comment={reply}
|
||||||
|
parentComment={comment}
|
||||||
|
onCommentDelete={onReplyDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
)
|
||||||
|
}
|
@ -1,27 +1,23 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { clickToUserprofile, useToLogin } 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 { Suspense, use, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import NeedLogin from "../common/need-login";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getGravatarByUser } from "../common/gravatar";
|
|
||||||
import { CircleUser, Reply, Trash } from "lucide-react";
|
|
||||||
import { Textarea } from "../ui/textarea";
|
|
||||||
import { Comment } from "@/models/comment";
|
import { Comment } from "@/models/comment";
|
||||||
import { createComment, deleteComment, listComments } from "@/api/comment";
|
import { createComment, deleteComment, listComments } from "@/api/comment";
|
||||||
import { TargetType } from "@/models/types";
|
import { TargetType } from "@/models/types";
|
||||||
import { OrderBy } from "@/models/common";
|
import { OrderBy } from "@/models/common";
|
||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getLoginUser } from "@/api/user";
|
import { getLoginUser } from "@/api/user";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { toggleLike } from "@/api/like";
|
|
||||||
import { useDoubleConfirm } from "@/hooks/use-double-confirm";
|
|
||||||
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import { CommentInput } from "./comment-input";
|
||||||
|
import { CommentItem } from "./comment-item";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
export function CommentSection(
|
export function CommentSection(
|
||||||
{
|
{
|
||||||
@ -55,28 +51,28 @@ export function CommentSection(
|
|||||||
orderBy: OrderBy.CreatedAt,
|
orderBy: OrderBy.CreatedAt,
|
||||||
desc: true,
|
desc: true,
|
||||||
page: 1,
|
page: 1,
|
||||||
size: DEFAULT_PAGE_SIZE,
|
size: config.commentsPerPage,
|
||||||
commentId: 0
|
commentId: 0
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
setComments(response.data);
|
setComments(response.data);
|
||||||
});
|
});
|
||||||
}, [refreshCommentsKey])
|
}, [refreshCommentsKey])
|
||||||
|
|
||||||
const onCommentSubmitted = (commentContent: string) => {
|
const onCommentSubmitted = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
|
||||||
createComment({
|
createComment({
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
replyId: null,
|
replyId: null,
|
||||||
isPrivate: false
|
isPrivate,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
toast.success(t("comment_success"));
|
toast.success(t("comment_success"));
|
||||||
setRefreshCommentsKey(k => k + 1);
|
setRefreshCommentsKey(k => k + 1);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCommentDelete = (commentId: number) => {
|
const onCommentDelete = ({ commentId }: { commentId: number }) => {
|
||||||
deleteComment(commentId).then(() => {
|
deleteComment({ id: commentId }).then(() => {
|
||||||
toast.success(t("delete_success"));
|
toast.success(t("delete_success"));
|
||||||
setRefreshCommentsKey(k => k + 1);
|
setRefreshCommentsKey(k => k + 1);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@ -111,156 +107,6 @@ export function CommentSection(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentInput(
|
|
||||||
{ user, onCommentSubmitted, }: { user: User | null, onCommentSubmitted: (commentContent: string) => void }
|
|
||||||
) {
|
|
||||||
const t = useTranslations('Comment')
|
|
||||||
const handleToLogin = useToLogin()
|
|
||||||
|
|
||||||
const [commentContent, setCommentContent] = useState("");
|
|
||||||
|
|
||||||
const handleCommentSubmit = async () => {
|
|
||||||
if (!user) {
|
|
||||||
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!commentContent.trim()) {
|
|
||||||
toast.error(t("content_required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCommentSubmitted(commentContent);
|
|
||||||
setCommentContent("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fade-in-up">
|
|
||||||
<div className="flex py-4 fade-in">
|
|
||||||
<div onClick={user ? clickToUserprofile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
|
||||||
{user && getGravatarByUser(user)}
|
|
||||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
|
||||||
<Textarea
|
|
||||||
placeholder={t("placeholder")}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
|
|
||||||
value={commentContent}
|
|
||||||
onChange={(e) => setCommentContent(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{commentContent.trim() && (
|
|
||||||
<div className="flex justify-end fade-in-up">
|
|
||||||
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
|
|
||||||
{t("submit")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommentItem(
|
|
||||||
{
|
|
||||||
user,
|
|
||||||
comment,
|
|
||||||
parentComment,
|
|
||||||
onCommentDelete,
|
|
||||||
}: {
|
|
||||||
user: User | null,
|
|
||||||
comment: Comment,
|
|
||||||
parentComment: Comment | null,
|
|
||||||
onCommentDelete: (commentId: number) => void,
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const t = useTranslations("Comment")
|
|
||||||
const { confirming, onClick, onBlur } = useDoubleConfirm();
|
|
||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(comment.likeCount);
|
|
||||||
const [liked, setLiked] = useState(comment.isLiked);
|
|
||||||
const [replyCount, setReplyCount] = useState(comment.replyCount);
|
|
||||||
const [showReplies, setShowReplies] = useState(false);
|
|
||||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
|
||||||
|
|
||||||
const handleToggleLike = () => {
|
|
||||||
toggleLike(
|
|
||||||
{ targetType: TargetType.Comment, targetId: comment.id }
|
|
||||||
).then(res => {
|
|
||||||
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
|
|
||||||
setLiked(res.data.status);
|
|
||||||
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
|
|
||||||
}).catch(error => {
|
|
||||||
toast.error(t("like_failed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const onReply = (replyContent: string) => {
|
|
||||||
setShowReplies(true);
|
|
||||||
setShowReplyInput(false);
|
|
||||||
setReplyCount(replyCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex">
|
|
||||||
<div className="fade-in">
|
|
||||||
{getGravatarByUser(comment.user)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
|
||||||
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
|
|
||||||
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
|
|
||||||
{
|
|
||||||
parentComment &&
|
|
||||||
<>{t("reply")} <button onClick={clickToUserprofile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </>
|
|
||||||
}
|
|
||||||
{comment.content}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
|
|
||||||
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
|
|
||||||
{/* 点赞按钮 */}
|
|
||||||
<button
|
|
||||||
title={t(liked ? "unlike" : "like")}
|
|
||||||
onClick={handleToggleLike}
|
|
||||||
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded
|
|
||||||
${liked ? 'bg-primary text-primary-foreground dark:text-white' : 'bg-slate-400 hover:bg-slate-600'}
|
|
||||||
dark:hover:bg-slate-500 fade-in`}
|
|
||||||
>
|
|
||||||
👍 {likeCount}
|
|
||||||
</button>
|
|
||||||
{/* 回复按钮 */}
|
|
||||||
<button
|
|
||||||
title={t("reply")}
|
|
||||||
onClick={() => setShowReplyInput(!showReplyInput)}
|
|
||||||
className="flex items-center justify-center px-2 py-1 h-5
|
|
||||||
text-primary-foreground dark:text-white text-xs
|
|
||||||
rounded 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
|
|
||||||
title={t("delete")}
|
|
||||||
onClick={() => onClick(() => { comment.id && onCommentDelete(comment.id); })}
|
|
||||||
onBlur={onBlur}
|
|
||||||
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded bg-red-500 text-white hover:bg-red-600 transition animated-btn fade-in`}
|
|
||||||
>
|
|
||||||
<Trash className="w-3 h-3" />
|
|
||||||
{confirming && (
|
|
||||||
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
|
|
||||||
)}
|
|
||||||
</button>)}
|
|
||||||
|
|
||||||
{replyCount > 0 &&
|
|
||||||
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
|
|
||||||
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{showReplyInput && <CommentInput user={user} onCommentSubmitted={onReply} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommentLoading() {
|
function CommentLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 py-8">
|
<div className="space-y-6 py-8">
|
||||||
|
32
web/src/components/ui/checkbox.tsx
Normal file
32
web/src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
@ -15,6 +15,7 @@ const config = {
|
|||||||
bodyWidth: "80vw",
|
bodyWidth: "80vw",
|
||||||
bodyWidthMobile: "100vw",
|
bodyWidthMobile: "100vw",
|
||||||
postsPerPage: 12,
|
postsPerPage: 12,
|
||||||
|
commentsPerPage: 20,
|
||||||
footer: {
|
footer: {
|
||||||
text: "Liteyuki ICP备 1145141919810",
|
text: "Liteyuki ICP备 1145141919810",
|
||||||
links: []
|
links: []
|
||||||
|
@ -12,11 +12,11 @@ export function useToLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clickToUserprofile(username: string) {
|
export function useToUserProfile() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
return () => {
|
return (username: string) => {
|
||||||
router.push(`/user/${username}`)
|
router.push(`/u/${username}`);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clickToPost(postId: number) {
|
export function clickToPost(postId: number) {
|
||||||
|
@ -3,25 +3,30 @@
|
|||||||
"title": "Hello world!"
|
"title": "Hello world!"
|
||||||
},
|
},
|
||||||
"Comment": {
|
"Comment": {
|
||||||
|
"collapse_replies": "收起",
|
||||||
"comment": "评论",
|
"comment": "评论",
|
||||||
"placeholder": "写你的评论...",
|
|
||||||
"submit": "提交",
|
|
||||||
"login_required": "请先登录后再评论。",
|
|
||||||
"content_required": "评论内容不能为空。",
|
|
||||||
"comment_success": "评论成功!",
|
|
||||||
"comment_failed": "评论失败",
|
"comment_failed": "评论失败",
|
||||||
"delete": "删除",
|
"comment_success": "评论成功!",
|
||||||
"delete_success": "评论已经删除",
|
|
||||||
"delete_failed": "删除评论失败",
|
|
||||||
"confirm_delete": "确定吗?",
|
"confirm_delete": "确定吗?",
|
||||||
|
"content_required": "评论内容不能为空。",
|
||||||
|
"delete": "删除",
|
||||||
|
"delete_failed": "删除评论失败",
|
||||||
|
"delete_success": "评论已经删除",
|
||||||
|
"edit": "编辑",
|
||||||
|
"edit_failed": "编辑评论失败",
|
||||||
|
"edit_success": "评论已更新",
|
||||||
|
"expand_replies": "展开 {count} 条",
|
||||||
"like": "点赞",
|
"like": "点赞",
|
||||||
"unlike": "取消点赞",
|
|
||||||
"like_success": "点赞成功",
|
|
||||||
"unlike_success": "已取消点赞",
|
|
||||||
"like_failed": "点赞失败",
|
"like_failed": "点赞失败",
|
||||||
|
"like_success": "点赞成功",
|
||||||
|
"login_required": "请先登录后再评论。",
|
||||||
|
"placeholder": "写你的评论...",
|
||||||
|
"private": "私密评论",
|
||||||
"reply": "回复",
|
"reply": "回复",
|
||||||
"expand_replies": "展开 {count} 条回复",
|
"submit": "提交",
|
||||||
"collapse_replies": "收起回复"
|
"unlike": "取消点赞",
|
||||||
|
"unlike_success": "已取消点赞",
|
||||||
|
"update": "更新"
|
||||||
},
|
},
|
||||||
"Login": {
|
"Login": {
|
||||||
"welcome": "欢迎回来",
|
"welcome": "欢迎回来",
|
||||||
|
@ -18,7 +18,5 @@ export interface Comment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCommentRequest {
|
export interface UpdateCommentRequest {
|
||||||
id: number
|
|
||||||
content: string
|
|
||||||
isPrivate?: boolean // 可选字段,默认为 false
|
|
||||||
}
|
}
|
Reference in New Issue
Block a user