feat: add email verification and password reset functionality

- Introduced environment variables for database and email configurations.
- Implemented email verification code generation and validation.
- Added password reset feature with email verification.
- Updated user registration and profile management APIs.
- Refactored user security settings to include email and password updates.
- Enhanced console layout with internationalization support.
- Removed deprecated settings page and integrated global settings.
- Added new reset password page and form components.
- Updated localization files for new features and translations.
This commit is contained in:
2025-09-23 00:33:34 +08:00
parent c9db6795b2
commit b0b32c93d1
32 changed files with 888 additions and 345 deletions

View File

@ -40,7 +40,7 @@ export async function userRegister(
return res.data
}
export async function ListOidcConfigs(): Promise<BaseResponse<OidcConfig[]>> {
export async function listOidcConfigs(): Promise<BaseResponse<OidcConfig[]>> {
const res = await axiosClient.get<BaseResponse<OidcConfig[]>>(
'/user/oidc/list',
)
@ -88,4 +88,24 @@ export async function getCaptchaConfig(): Promise<BaseResponse<{
export async function updateUser(data: Partial<User>): Promise<BaseResponse<User>> {
const res = await axiosClient.put<BaseResponse<User>>(`/user/u/${data.id}`, data)
return res.data
}
export async function requestEmailVerifyCode(email: string): Promise<BaseResponse<{ coolDown: number }>> {
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email })
return res.data
}
export async function updatePassword({ oldPassword, newPassword }: { oldPassword: string, newPassword: string }): Promise<BaseResponse<null>> {
const res = await axiosClient.put<BaseResponse<null>>('/user/password/edit', { oldPassword, newPassword })
return res.data
}
export async function resetPassword({ email, newPassword, verifyCode }: { email: string, newPassword: string, verifyCode: string }): Promise<BaseResponse<null>> {
const res = await axiosClient.put<BaseResponse<null>>('/user/password/reset', { newPassword }, { headers: { 'X-Email': email, 'X-VerifyCode': verifyCode } })
return res.data
}
export async function updateEmail({ newEmail, verifyCode }: { newEmail: string, verifyCode: string }): Promise<BaseResponse<null>> {
const res = await axiosClient.put<BaseResponse<null>>('/user/email/edit', null, { headers: { 'X-Email': newEmail, 'X-VerifyCode': verifyCode } })
return res.data
}

View File

@ -0,0 +1,5 @@
import GlobalPage from "@/components/console/global";
export default function Page() {
return <GlobalPage />;
}

View File

@ -11,12 +11,14 @@ import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { sidebarData, SidebarItem } from "@/components/console/data"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
export default function ConsoleLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const t = useTranslations("Console")
const { user } = useAuth();
const [title, setTitle] = useState("Title");
const toLogin = useToLogin();
@ -27,12 +29,12 @@ export default function ConsoleLayout({
useEffect(() => {
const currentItem = sideBarItems.find(item => item.url === pathname);
if (currentItem) {
setTitle(currentItem.title);
document.title = `${currentItem.title} - 控制台`;
setTitle(t(currentItem.title));
document.title = `${t(currentItem.title)} - 控制台`;
} else {
setTitle("Title");
}
}, [pathname, sideBarItems]);
}, [pathname, sideBarItems, t]);
useEffect(() => {
if (!user) {

View File

@ -1,5 +0,0 @@
import SettingPage from "@/components/console/setting";
export default function Page() {
return <SettingPage />;
}

View File

@ -0,0 +1,25 @@
import { ResetPasswordForm } from "@/components/reset-password/reset-password-form";
import config from "@/config";
import Image from "next/image";
export default function Page() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="#" className="flex items-center gap-3 self-center font-bold text-2xl">
<div className="flex size-10 items-center justify-center rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
<Image
src={config.metadata.icon}
alt="Logo"
width={40}
height={40}
className="rounded-full object-cover"
/>
</div>
<span className="font-bold text-2xl">{config.metadata.name}</span>
</a>
<ResetPasswordForm />
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from "react"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
export function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
const [value, setValue] = useState("")
useEffect(() => {
onChange(value)
}, [value, onChange])
return (
<div className="space-y-2">
<InputOTP
maxLength={6}
value={value}
onChange={(value) => setValue(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
)
}

View File

@ -14,57 +14,57 @@ export interface SidebarItem {
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
navMain: [
{
title: "大石坝",
title: "dashboard.title",
url: "/console",
icon: Gauge,
permission: isAdmin
},
{
title: "文章管理",
title: "post.title",
url: "/console/post",
icon: Newspaper,
permission: isEditor
},
{
title: "评论管理",
title: "comment.title",
url: "/console/comment",
icon: MessageCircle,
permission: isEditor
},
{
title: "文件管理",
title: "file.title",
url: "/console/file",
icon: Folder,
permission: () => true
},
{
title: "用户管理",
title: "user.title",
url: "/console/user",
icon: Users,
permission: isAdmin
},
{
title: "全局设置",
url: "/console/setting",
title: "global.title",
url: "/console/global",
icon: Settings,
permission: isAdmin
},
],
navUserCenter: [
{
title: "个人资料",
title: "user_profile.title",
url: "/console/user-profile",
icon: UserPen,
permission: () => true
},
{
title: "安全设置",
title: "user_security.title",
url: "/console/user-security",
icon: ShieldCheck,
permission: () => true
},
{
title: "个性化",
title: "user-preference.title",
url: "/console/user-preference",
icon: Palette,
permission: () => true

View File

@ -1,7 +1,7 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function SettingPage() {
export default function GlobalPage() {
return <div>
<h2 className="text-2xl font-bold">

View File

@ -13,6 +13,7 @@ import { usePathname } from "next/navigation";
import { User } from "@/models/user";
import { useAuth } from "@/contexts/auth-context";
import { IconType } from "@/types/icon";
import { useTranslations } from "next-intl";
export function NavMain({
items,
@ -24,6 +25,7 @@ export function NavMain({
permission: ({ user }: { user: User }) => boolean
}[]
}) {
const t = useTranslations("Console")
const { user } = useAuth();
const pathname = usePathname() ?? "/"
@ -39,7 +41,7 @@ export function NavMain({
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>

View File

@ -1,6 +1,5 @@
"use client"
import {
SidebarGroup,
SidebarGroupLabel,
@ -13,6 +12,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { useAuth } from "@/contexts/auth-context"
import { IconType } from "@/types/icon"
import { useTranslations } from "next-intl"
export function NavUserCenter({
items,
@ -24,6 +24,7 @@ export function NavUserCenter({
permission: ({ user }: { user: User }) => boolean
}[]
}) {
const t = useTranslations("Console")
const { user } = useAuth();
const pathname = usePathname() ?? "/"
@ -38,7 +39,7 @@ export function NavUserCenter({
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>

View File

@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/auth-context";
import { getFileUri } from "@/utils/client/file";
import { getGravatarFromUser } from "@/utils/common/gravatar";
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@ -25,9 +26,8 @@ interface PictureInputChangeEvent {
}
export function UserProfilePage() {
const t = useTranslations("Console.user_profile")
const { user } = useAuth();
const [nickname, setNickname] = useState(user?.nickname || '')
const [username, setUsername] = useState(user?.username || '')
const [avatarFile, setAvatarFile] = useState<File | null>(null)
@ -62,12 +62,12 @@ export function UserProfilePage() {
};
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
setAvatarFile(null);
toast.error('只允许上传 PNG / JPEG / WEBP / GIF 格式的图片');
toast.error(t("only_allow_picture"));
return;
}
if (file.size > constraints.maxSize) {
setAvatarFile(null);
toast.error('图片大小不能超过 5MB');
toast.error(t("picture_size_cannot_exceed", {"size": "5MiB"}));
return;
}
setAvatarFile(file);
@ -79,15 +79,15 @@ export function UserProfilePage() {
nickname.trim() === '' ||
username.trim() === ''
) {
toast.error('Nickname and Username cannot be empty')
toast.error(t("nickname_and_username_cannot_be_empty"))
return
}
if (
(username.length < 3 || username.length > 20) ||
(username.length < 1 || username.length > 20) ||
(nickname.length < 1 || nickname.length > 20)
) {
toast.error('Nickname and Username must be between 3 and 20 characters')
toast.error(t("nickname_and_username_must_be_between", {"min": 1, "max": 20}))
return
}
@ -97,7 +97,7 @@ export function UserProfilePage() {
gender === user.gender &&
avatarFile === null
) {
toast.warning('No changes made')
toast.warning(t("no_changes_made"))
return
}
@ -108,19 +108,17 @@ export function UserProfilePage() {
try {
const resp = await uploadFile({ file: avatarFile });
avatarUrl = getFileUri(resp.data.id);
console.log('Uploaded avatar, got URL:', avatarUrl);
} catch (error: unknown) {
toast.error(`Failed to upload avatar ${error}`);
toast.error(`${t("failed_to_upload_avatar")}: ${error}`);
return;
}
}
try {
await updateUser({ nickname, username, avatarUrl, gender, id: user.id });
toast.success('Profile updated successfully');
window.location.reload();
} catch (error: unknown) {
toast.error(`Failed to update profile ${error}`);
toast.error(`${t("failed_to_update_profile")}: ${error}`);
} finally {
setSubmitting(false);
}
@ -138,11 +136,11 @@ export function UserProfilePage() {
return (
<div>
<h1 className="text-2xl font-bold">
Public Profile
{t("public_profile")}
</h1>
<Separator className="my-2" />
<div className="grid w-full max-w-sm items-center gap-3">
<Label htmlFor="picture">Picture</Label>
<Label htmlFor="picture">{t("picture")}</Label>
<Avatar className="h-40 w-40 rounded-xl border-2">
{avatarFileUrl ?
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
@ -157,13 +155,13 @@ export function UserProfilePage() {
/>
<ImageCropper image={avatarFile} onCropped={handleCropped} />
</div>
<Label htmlFor="nickname">Nickname</Label>
<Label htmlFor="nickname">{t("nickname")}</Label>
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t("username")}</Label>
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Label htmlFor="gender">Gender</Label>
<Label htmlFor="gender">{t("gender")}</Label>
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)}/>
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>Submit{submitting && '...'}</Button>
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>{t("update_profile")}{submitting && '...'}</Button>
</div>
</div>
)

View File

@ -3,82 +3,95 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { useEffect, useState } from "react";
import { useState } from "react";
import { requestEmailVerifyCode, updateEmail, updatePassword } from "@/api/user";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { BaseErrorResponse } from "@/models/resp";
import { useAuth } from "@/contexts/auth-context";
import { useToResetPassword } from "@/hooks/use-route";
import { InputOTPControlled } from "@/components/common/input-otp";
// const VERIFY_CODE_COOL_DOWN = 60; // seconds
export function UserSecurityPage() {
const [email, setEmail] = useState("")
const t = useTranslations("Console.user_security")
const { user, setUser } = useAuth();
const toResetPassword = useToResetPassword();
const [email, setEmail] = useState(user?.email || "")
const [verifyCode, setVerifyCode] = useState("")
const [oldPassword, setOldPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const handleSubmitPassword = () => {
const handleSubmitPassword = () => {
updatePassword({ oldPassword, newPassword }).then(() => {
toast.success(t("update_password_success"))
setOldPassword("")
setNewPassword("")
}).catch((error: BaseErrorResponse) => {
toast.error(`${t("update_password_failed")}: ${error.response.data.message}`)
})
}
const handleSendVerifyCode = () => {
console.log("send verify code to ", email)
requestEmailVerifyCode(email)
.then(() => {
toast.success(t("send_verify_code_success"))
})
.catch((error: BaseErrorResponse) => {
console.log("error", error)
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
})
}
const handleSubmitEmail = () => {
console.log("submit email ", email, verifyCode)
updateEmail({ newEmail: email, verifyCode }).then(() => {
toast.success(t("update_email_success"))
if (user) {
setUser({
...user,
email,
})
}
setVerifyCode("")
}).catch((error: BaseErrorResponse) => {
toast.error(`${t("update_email_failed")}: ${error.response.data.message}`)
})
}
if (!user) return null;
return (
<div>
<div className="grid w-full max-w-sm items-center gap-3">
<h1 className="text-2xl font-bold">
{t("password_setting")}
</h1>
<Label htmlFor="password">Old Password</Label>
<Label htmlFor="password">{t("old_password")}</Label>
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
<Label htmlFor="password">New Password</Label>
<Label htmlFor="password">{t("new_password")}</Label>
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<Button className="max-w-1/3 border-2" onClick={handleSubmitPassword}>Submit</Button>
<div className="flex w-full items-center justify-between">
<Button disabled={!oldPassword || !newPassword} className="max-w-1/3 border-2" onClick={handleSubmitPassword}>{t("update_password")}</Button>
<Button onClick={toResetPassword} variant="ghost">{t("forgot_password_or_no_password")}</Button>
</div>
</div>
<Separator className="my-4" />
<div className="grid w-full max-w-sm items-center gap-3 py-4">
<h1 className="text-2xl font-bold">
{t("email_setting")}
</h1>
<Label htmlFor="email">email</Label>
<Label htmlFor="email">{t("email")}</Label>
<div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button variant="outline" className="border-2" onClick={handleSendVerifyCode}></Button>
<Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
</div>
<Label htmlFor="verify-code">verify code</Label>
<Label htmlFor="verify-code">{t("verify_code")}</Label>
<div className="flex gap-3">
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
<Button className="border-2" onClick={handleSubmitEmail}>Submit</Button>
<Button disabled={verifyCode.length < 6} className="border-2" onClick={handleSubmitEmail}>{t("update_email")}</Button>
</div>
</div>
</div>
)
}
function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
const [value, setValue] = useState("")
useEffect(() => {
onChange(value)
}, [value, onChange])
return (
<div className="space-y-2">
<InputOTP
maxLength={6}
value={value}
onChange={(value) => setValue(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
)
}

View File

@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label"
import Image from "next/image"
import { useEffect, useState } from "react"
import type { OidcConfig } from "@/models/oidc-config"
import { getCaptchaConfig, ListOidcConfigs, userLogin } from "@/api/user"
import { getCaptchaConfig, listOidcConfigs, userLogin } from "@/api/user"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
@ -22,12 +22,14 @@ import Captcha from "../common/captcha"
import { CaptchaProvider } from "@/models/captcha"
import { toast } from "sonner"
import { useAuth } from "@/contexts/auth-context"
import { resetPasswordPath, useToResetPassword } from "@/hooks/use-route"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const t = useTranslations('Login')
const toResetPassword = useToResetPassword();
const {user, setUser} = useAuth();
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
const [captchaProps, setCaptchaProps] = useState<{
@ -50,7 +52,7 @@ export function LoginForm({
}, [user, router, redirectBack]);
useEffect(() => {
ListOidcConfigs()
listOidcConfigs()
.then((res) => {
setOidcConfigs(res.data || [])
})
@ -158,12 +160,12 @@ export function LoginForm({
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">{t("password")}</Label>
<a
href="#"
<Link
href={resetPasswordPath}
className="ml-auto text-sm underline-offset-4 hover:underline"
>
{t("forgot_password")}
</a>
</Link>
</div>
<Input
id="password"
@ -179,7 +181,7 @@ export function LoginForm({
</div>
}
<Button
type="submit"
type="button"
className="w-full"
onClick={handleLogin}
disabled={!captchaToken || isLogging}
@ -191,9 +193,9 @@ export function LoginForm({
{/* 注册链接 */}
<div className="text-center text-sm">
{t("no_account")}{" "}
<a href="#" className="underline underline-offset-4">
<Link href="#" className="underline underline-offset-4">
{t("register")}
</a>
</Link>
</div>
</div>
</form>

View File

@ -0,0 +1,103 @@
"use client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import Image from "next/image"
import { useState } from "react"
import { requestEmailVerifyCode, resetPassword } from "@/api/user"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { InputOTPControlled } from "@/components/common/input-otp"
import { BaseErrorResponse } from "@/models/resp"
import { loginPath, useToLogin } from "@/hooks/use-route"
import router from "next/router"
export function ResetPasswordForm({
className,
...props
}: React.ComponentProps<"div">) {
const t = useTranslations('ResetPassword')
const toLogin = useToLogin();
const [email, setEmail] = useState("")
const [verifyCode, setVerifyCode] = useState("")
const [newPassword, setNewPassword] = useState("")
const handleSendVerifyCode = () => {
requestEmailVerifyCode(email)
.then(() => {
toast.success(t("send_verify_code_success"))
})
.catch((error: BaseErrorResponse) => {
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
})
}
const handleResetPassword = () => {
resetPassword({ email, newPassword, verifyCode }).then(() => {
toast.success(t("reset_password_success"))
router.push(loginPath);
}).catch((error: BaseErrorResponse) => {
toast.error(`${t("reset_password_failed")}: ${error.response.data.message}`)
})
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">{t("title")}</CardTitle>
</CardHeader>
<CardContent>
<form>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="password">{t("new_password")}</Label>
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="grid gap-3">
<Label htmlFor="email">{t("email")}</Label>
<div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button
disabled={!email}
variant="outline"
className="border-2"
type="button"
onClick={handleSendVerifyCode}>{t("send_verify_code")}
</Button>
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="verify_code">{t("verify_code")}</Label>
<InputOTPControlled onChange={value => setVerifyCode(value)} />
</div>
<Button
type="button"
className="w-full"
disabled={!email || !newPassword || !verifyCode}
onClick={handleResetPassword}
>
{t("title")}
</Button>
</div>
{/* TODO 回归登录和注册链接 */}
</div>
</form>
</CardContent>
</Card>
{/* 服务条款 */}
</div>
)
}

View File

@ -5,11 +5,22 @@ import { useRouter, usePathname } from "next/navigation"
* 用于跳转到登录页并自动带上 redirect_back 参数
* 用法const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
*/
export const loginPath = "/login"
export const resetPasswordPath = "/reset-password"
export function useToLogin() {
const router = useRouter()
const pathname = usePathname()
return () => {
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
router.push(`${loginPath}?redirect_back=${encodeURIComponent(pathname)}`)
}
}
export function useToResetPassword() {
const router = useRouter()
const pathname = usePathname()
return () => {
router.push(`${resetPasswordPath}?redirect_back=${encodeURIComponent(pathname)}`)
}
}

View File

@ -53,15 +53,74 @@
"unlike_success": "已取消点赞",
"update": "更新"
},
"Common":{
"Common": {
"login": "登录",
"daysAgo": "天前",
"hoursAgo": "小时前",
"minutesAgo": "分钟前",
"secondsAgo": "秒前"
"secondsAgo": "秒前",
"submit": "提交",
"update": "更新"
},
"Console": {
"login_required": "请先登录再进入后台"
"comment": {
"title": "评论管理"
},
"dashboard": {
"title": "大石坝"
},
"file": {
"title": "文件管理"
},
"global": {
"title": "全局配置"
},
"login_required": "请先登录再进入后台",
"post": {
"title": "文章管理"
},
"user": {
"title": "用户管理"
},
"user_profile": {
"title": "个人资料",
"edit": "编辑",
"failed_to_upload_avatar": "上传头像失败",
"failed_to_update_profile": "更新个人资料失败",
"gender": "性别",
"nickname": "昵称",
"nickname_and_username_cannot_be_empty": "昵称和用户名不能为空",
"nickname_and_username_must_be_between": "昵称和用户名长度必须在{min}~{max}之间",
"no_changes_made": "没有修改任何内容",
"only_allow_picture": "仅允许上传图片格式PNG / JPEG / WEBP / GIF",
"picture": "头像",
"picture_size_cannot_exceed": "图片大小不能超过{size}",
"public_profile": "公开资料",
"username": "用户名",
"update_profile": "更新资料"
},
"user_security": {
"title": "安全设置",
"email": "邮箱",
"email_setting": "邮箱设置",
"forgot_password_or_no_password": "忘记密码或没有密码",
"new_password": "新密码",
"old_password": "旧密码",
"password_setting": "密码设置",
"send_verify_code": "发送验证码",
"send_verify_code_failed": "发送验证码失败",
"send_verify_code_success": "验证码已发送",
"update_email": "更新邮箱",
"update_email_failed": "更新邮箱失败",
"update_email_success": "邮箱已更新",
"update_password": "更新密码",
"update_password_failed": "更新密码失败",
"update_password_success": "密码已更新",
"verify_code": "验证码"
},
"user-preference": {
"title": "个性化"
}
},
"Login": {
"captcha_error": "验证错误,请重试。",
@ -83,5 +142,17 @@
"terms_of_service": "服务条款",
"and": "和",
"privacy_policy": "隐私政策"
},
"ResetPassword": {
"title": "重置密码",
"email": "邮箱",
"new_password": "新密码",
"reset_password": "重置密码",
"reset_password_failed": "重置密码失败",
"reset_password_success": "密码已重置,请使用新密码登录",
"send_verify_code": "发送验证码",
"send_verify_code_failed": "发送验证码失败",
"send_verify_code_success": "验证码已发送",
"verify_code": "验证码"
}
}

View File

@ -1,5 +1,13 @@
import { AxiosError, AxiosResponse } from "axios";
export interface BaseResponse<T> {
data: T;
message: string;
status: number;
}
export interface BaseErrorResponse<T = unknown, E = Record<string, unknown>> extends AxiosError<T> {
response: AxiosResponse & {
data: E & BaseResponse<null>;
};
}