mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-23 17:46:22 +00:00
✨ feat: 添加评论功能,重构评论输入和列表组件,支持多种目标类型,更新国际化文本
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 18s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 18s
This commit is contained in:
22
README.md
22
README.md
@ -110,5 +110,27 @@ pnpm start
|
|||||||
|
|
||||||
可以通过环境变量或者.env.production文件配置后端API端点
|
可以通过环境变量或者.env.production文件配置后端API端点
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动后端服务器
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入前端目录
|
||||||
|
cd web
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 启动前端开发服务器
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
## 环境变量配置
|
## 环境变量配置
|
||||||
后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中
|
后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中
|
@ -2,9 +2,10 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
@ -124,9 +125,12 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
||||||
currentUser, _ := ctxutils.GetCurrentUser(ctx)
|
currentUserID := uint(0)
|
||||||
|
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
|
||||||
|
currentUserID = currentUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/co
|
|||||||
import type { PaginationParams } from '@/models/common'
|
import type { PaginationParams } from '@/models/common'
|
||||||
import { OrderBy } from '@/models/common'
|
import { OrderBy } from '@/models/common'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
|
import { TargetType } from '@/models/types'
|
||||||
|
|
||||||
|
|
||||||
export async function createComment(
|
export async function createComment(
|
||||||
@ -24,7 +25,7 @@ export async function deleteComment(id: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListCommentsParams {
|
export interface ListCommentsParams {
|
||||||
targetType: 'post' | 'page'
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
depth?: number
|
depth?: number
|
||||||
orderBy?: OrderBy
|
orderBy?: OrderBy
|
||||||
|
@ -5,6 +5,7 @@ import { RenderMarkdown } from "@/components/common/markdown";
|
|||||||
import { isMobileByUA } from "@/utils/server/device";
|
import { isMobileByUA } from "@/utils/server/device";
|
||||||
import { calculateReadingTime } from "@/utils/common/post";
|
import { calculateReadingTime } from "@/utils/common/post";
|
||||||
import CommentSection from "@/components/comment";
|
import CommentSection from "@/components/comment";
|
||||||
|
import { TargetType } from '../../models/types';
|
||||||
|
|
||||||
function PostMeta({ post }: { post: Post }) {
|
function PostMeta({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
@ -138,7 +139,7 @@ async function BlogPost({ post }: { post: Post }) {
|
|||||||
{/* <ScrollToTop /> */}
|
{/* <ScrollToTop /> */}
|
||||||
<PostHeader post={post} />
|
<PostHeader post={post} />
|
||||||
<PostContent post={post} />
|
<PostContent post={post} />
|
||||||
<CommentSection targetType="post" targetId={post.id} />
|
<CommentSection targetType={TargetType.Post} targetId={post.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar"
|
import { getGravatarByUser } from "@/components/common/gravatar"
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { User } from "@/models/user";
|
import type { User } from "@/models/user";
|
||||||
import { getLoginUser } from "@/api/user";
|
import { getLoginUser } from "@/api/user";
|
||||||
|
import { createComment } from "@/api/comment";
|
||||||
|
|
||||||
|
import { CircleUser } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { TargetType } from "@/models/types";
|
||||||
|
import { useToLogin } from "@/hooks/use-to-login";
|
||||||
|
import NeedLogin from "../common/need-login";
|
||||||
|
|
||||||
|
export function CommentInput(
|
||||||
|
{ targetId, targetType, onCommentSubmitted }: { targetId: number, targetType: TargetType, onCommentSubmitted: () => void }
|
||||||
|
) {
|
||||||
|
const t = useTranslations('Comment')
|
||||||
|
const toLogin = useToLogin()
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [commentContent, setCommentContent] = useState("");
|
||||||
|
|
||||||
export function CommentInput() {
|
|
||||||
const [user, setUser] = useState<User | null>(null); // 假设 User 是你的用户模型
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getLoginUser()
|
getLoginUser()
|
||||||
.then(response => {
|
.then(response => {
|
||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
|
||||||
console.error("获取用户信息失败:", error);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCommentSubmit = async () => {
|
||||||
|
if (!user) {
|
||||||
|
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!commentContent.trim()) {
|
||||||
|
toast.error(t("content_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createComment({
|
||||||
|
targetType: targetType,
|
||||||
|
targetId: targetId,
|
||||||
|
content: commentContent,
|
||||||
|
}).then(response => {
|
||||||
|
setCommentContent("");
|
||||||
|
toast.success(t("comment_success"));
|
||||||
|
onCommentSubmitted();
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("comment_failed") + ": " +
|
||||||
|
error?.response?.data?.message || error?.message
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex py-4">
|
<div className="flex py-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div>
|
<div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10">
|
||||||
{user && getGravatarByUser(user)}
|
{user && getGravatarByUser(user)}
|
||||||
|
{!user && <CircleUser className="w-full h-full" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className="flex-1 pl-2">
|
<div className="flex-1 pl-2">
|
||||||
<Textarea placeholder="写下你的评论..." className="w-full p-2 border border-gray-300 rounded-md" />
|
<Textarea
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md"
|
||||||
|
value={commentContent}
|
||||||
|
onChange={(e) => setCommentContent(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
||||||
提交
|
{t("submit")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,22 +2,24 @@
|
|||||||
|
|
||||||
import type { Comment } from "@/models/comment";
|
import type { Comment } from "@/models/comment";
|
||||||
import { CommentInput } from "@/components/comment/comment-input";
|
import { CommentInput } from "@/components/comment/comment-input";
|
||||||
import { useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { listComments } from "@/api/comment";
|
import { listComments } from "@/api/comment";
|
||||||
import { OrderBy } from "@/models/common";
|
import { OrderBy } from "@/models/common";
|
||||||
import { CommentItem } from "./comment-item";
|
import { CommentItem } from "./comment-item";
|
||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "../ui/separator";
|
||||||
|
import { TargetType } from "@/models/types";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
interface CommentAreaProps {
|
interface CommentAreaProps {
|
||||||
targetType: 'post' | 'page';
|
targetType: TargetType;
|
||||||
targetId: number;
|
targetId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function CommentSection(props: CommentAreaProps) {
|
export default function CommentSection(props: CommentAreaProps) {
|
||||||
const { targetType, targetId } = props;
|
const { targetType, targetId } = props;
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [newComment, setNewComment] = useState<string>("");
|
const [newComment, setNewComment] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -33,30 +35,57 @@ export default function CommentSection(props: CommentAreaProps) {
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
setComments(response.data);
|
setComments(response.data);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
|
||||||
setError("加载评论失败,请稍后再试。");
|
|
||||||
console.error("Error loading comments:", err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [targetType, targetId]);
|
}, [targetType, targetId]);
|
||||||
|
|
||||||
|
const onCommentSubmitted = () => {
|
||||||
|
// 重新加载评论列表
|
||||||
|
listComments({
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
depth: 0,
|
||||||
|
orderBy: OrderBy.CreatedAt,
|
||||||
|
desc: true,
|
||||||
|
page: 1,
|
||||||
|
size: 10
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
setComments(response.data);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Separator className="my-16" />
|
<Separator className="my-16" />
|
||||||
<div className="font-bold text-2xl">评论</div>
|
<div className="font-bold text-2xl">评论</div>
|
||||||
<CommentInput />
|
<CommentInput targetType={targetType} targetId={targetId} onCommentSubmitted={onCommentSubmitted} />
|
||||||
{loading && <p>加载中...</p>}
|
|
||||||
{error && <p className="text-red-500">{error}</p>}
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
<Suspense fallback={<CommentLoading />}>
|
||||||
{comments.map(comment => (
|
{comments.map(comment => (
|
||||||
<div key={comment.id}>
|
<div key={comment.id}>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<CommentItem {...comment} />
|
<CommentItem {...comment} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function CommentLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 py-8">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-3">
|
||||||
|
<Skeleton className="w-10 h-10 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/4" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
7
web/src/components/common/image-placeholder.tsx
Normal file
7
web/src/components/common/image-placeholder.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function ImagePlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="w-10 h-10 bg-gray-200 flex items-center justify-center rounded-full">
|
||||||
|
<img src="/file.svg" alt="Image Placeholder" className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
12
web/src/components/common/need-login.tsx
Normal file
12
web/src/components/common/need-login.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useToLogin } from "@/hooks/use-to-login";
|
||||||
|
|
||||||
|
export default function NeedLogin(
|
||||||
|
{ children }: { children?: React.ReactNode }
|
||||||
|
) {
|
||||||
|
const toLogin = useToLogin()
|
||||||
|
return (
|
||||||
|
<div onClick={toLogin}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -17,11 +17,13 @@ import type { OidcConfig } from "@/models/oidc-config"
|
|||||||
import { ListOidcConfigs, userLogin } from "@/api/user"
|
import { ListOidcConfigs, userLogin } from "@/api/user"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
|
const t = useTranslations('Login')
|
||||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||||
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -55,9 +57,9 @@ export function LoginForm({
|
|||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
<CardTitle className="text-xl">{t("welcome")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Login with Open ID Connect.
|
{t("with_oidc")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -67,15 +69,15 @@ export function LoginForm({
|
|||||||
{oidcConfigs.length > 0 && (
|
{oidcConfigs.length > 0 && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{oidcConfigs.map((config, index) => {
|
{oidcConfigs.map((config, index) => {
|
||||||
// 生成唯一的 key
|
|
||||||
const uniqueKey = config.id ||
|
const uniqueKey = config.id ||
|
||||||
config.loginUrl ||
|
config.loginUrl ||
|
||||||
`${config.displayName}-${index}` ||
|
`${config.displayName}-${index}` ||
|
||||||
`oidc-${index}`;
|
`oidc-${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginWithOidc
|
<LoginWithOidc
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
|
// 这个REDIRECT_BACK需要前端自己拼接,传给后端服务器,后端服务器拿来响应给前端另一个页面获取,然后改变路由
|
||||||
|
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
||||||
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
||||||
displayName={config.displayName}
|
displayName={config.displayName}
|
||||||
icon={config.icon}
|
icon={config.icon}
|
||||||
@ -89,7 +91,7 @@ export function LoginForm({
|
|||||||
{oidcConfigs.length > 0 && (
|
{oidcConfigs.length > 0 && (
|
||||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||||
Or continue with
|
{t("or_continue_with_local_account")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -97,7 +99,7 @@ export function LoginForm({
|
|||||||
{/* 邮箱密码登录 */}
|
{/* 邮箱密码登录 */}
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label htmlFor="email">Email or Username</Label>
|
<Label htmlFor="email">{t("email_or_username")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="text"
|
type="text"
|
||||||
@ -109,12 +111,12 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Forgot your password?
|
{t("forgot_password")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@ -126,15 +128,15 @@ export function LoginForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" onClick={handleLogin}>
|
<Button type="submit" className="w-full" onClick={handleLogin}>
|
||||||
Login
|
{t("login")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 注册链接 */}
|
{/* 注册链接 */}
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
Don't have an account?{" "}
|
{t("no_account")}{" "}
|
||||||
<a href="#" className="underline underline-offset-4">
|
<a href="#" className="underline underline-offset-4">
|
||||||
Sign up
|
{t("register")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,14 +146,14 @@ export function LoginForm({
|
|||||||
|
|
||||||
{/* 服务条款 */}
|
{/* 服务条款 */}
|
||||||
<div className="text-muted-foreground text-center text-xs text-balance">
|
<div className="text-muted-foreground text-center text-xs text-balance">
|
||||||
By clicking continue, you agree to our{" "}
|
{t("by_logging_in_you_agree_to_our")}{" "}
|
||||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
Terms of Service
|
{t("terms_of_service")}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
and{" "}
|
{t("and")}{" "}
|
||||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||||
Privacy Policy
|
{t("privacy_policy")}
|
||||||
</a>.
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
0
web/src/components/login/to-login.tsx
Normal file
0
web/src/components/login/to-login.tsx
Normal file
13
web/src/components/ui/skeleton.tsx
Normal file
13
web/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
13
web/src/hooks/use-to-login.ts
Normal file
13
web/src/hooks/use-to-login.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { useRouter, usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于跳转到登录页并自动带上 redirect_back 参数
|
||||||
|
* 用法:const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
|
||||||
|
*/
|
||||||
|
export function useToLogin() {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
return () => {
|
||||||
|
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,29 @@
|
|||||||
{
|
{
|
||||||
"HomePage": {
|
"HomePage": {
|
||||||
"title": "Hello world!"
|
"title": "Hello world!"
|
||||||
|
},
|
||||||
|
"Comment": {
|
||||||
|
"placeholder": "写下你的评论...",
|
||||||
|
"submit": "提交",
|
||||||
|
"login_required": "请先登录后再评论。",
|
||||||
|
"content_required": "评论内容不能为空。",
|
||||||
|
"comment_success": "评论提交成功!",
|
||||||
|
"comment_failed": "评论提交失败"
|
||||||
|
},
|
||||||
|
"Login": {
|
||||||
|
"welcome": "欢迎回来",
|
||||||
|
"with_oidc": "使用第三方身份提供者",
|
||||||
|
"or_continue_with_local_account": "或使用用户名和密码",
|
||||||
|
"email_or_username": "邮箱或用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"forgot_password": "忘记密码?",
|
||||||
|
"no_account": "还没有账号?",
|
||||||
|
"register": "注册",
|
||||||
|
"login": "登录",
|
||||||
|
"by_logging_in_you_agree_to_our": "登录即表示你同意我们的",
|
||||||
|
"terms_of_service": "服务条款",
|
||||||
|
"and": "和",
|
||||||
|
"privacy_policy": "隐私政策",
|
||||||
|
"login_failed": "登录失败,请检查你的凭据。"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
|
import { TargetType } from "./types"
|
||||||
import type { User } from "./user"
|
import type { User } from "./user"
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number
|
id: number
|
||||||
targetType: string
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
content: string
|
content: string
|
||||||
replyId: number
|
replyId: number
|
||||||
@ -14,7 +15,7 @@ export interface Comment{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCommentRequest {
|
export interface CreateCommentRequest {
|
||||||
targetType: string
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
content: string
|
content: string
|
||||||
replyId?: number // 可选字段,默认为 null
|
replyId?: number // 可选字段,默认为 null
|
||||||
|
9
web/src/models/types.ts
Normal file
9
web/src/models/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// 目标类型枚举
|
||||||
|
export enum TargetType {
|
||||||
|
Post = "post",
|
||||||
|
Page = "page",
|
||||||
|
Article = "article",
|
||||||
|
Video = "video",
|
||||||
|
Image = "image",
|
||||||
|
Other = "other"
|
||||||
|
}
|
@ -13,7 +13,7 @@ export interface User {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
rememberMe?: boolean // 可以轻松添加新字段
|
rememberMe?: boolean
|
||||||
captcha?: string
|
captcha?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user