feat: 添加当前管理员修改密码功能
All checks were successful
Build & Deploy Admin / deploy (push) Successful in 28s

在侧边栏底部新增"修改密码"入口,支持输入当前密码和新密码修改自己的登录密码。
This commit is contained in:
Wang Zhuoxuan 2026-04-23 12:34:45 +08:00
parent 91f88acba7
commit 165625f362
3 changed files with 270 additions and 33 deletions

View File

@ -0,0 +1,204 @@
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod/v4"
import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { changePassword } from "@/lib/api/admin-api"
const schema = z
.object({
currentPassword: z.string().min(1, "请输入当前密码"),
newPassword: z.string().min(8, "新密码至少8个字符").max(128, "密码不超过128个字符"),
confirmPassword: z.string().min(1, "请确认新密码"),
})
.refine((d) => d.newPassword === d.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
})
.refine((d) => d.currentPassword !== d.newPassword, {
message: "新密码不能与当前密码相同",
path: ["newPassword"],
})
type FormValues = z.infer<typeof schema>
interface ChangePasswordDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
const [submitting, setSubmitting] = useState(false)
const [serverError, setServerError] = useState("")
const [success, setSuccess] = useState(false)
const [showCurrent, setShowCurrent] = useState(false)
const [showNew, setShowNew] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
})
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) {
form.reset()
setServerError("")
setSuccess(false)
}
onOpenChange(nextOpen)
}
async function handleSubmit(data: FormValues) {
setServerError("")
setSubmitting(true)
try {
const res = await changePassword({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
})
if (res.success) {
setSuccess(true)
} else {
const code = res.error?.code ?? ""
if (code === "UNAUTHORIZED") {
setServerError("当前密码错误")
} else if (code === "VALIDATION_ERROR") {
setServerError(res.error?.message ?? "参数校验失败")
} else if (code === "FORBIDDEN") {
setServerError("账户已被禁用")
} else {
setServerError(res.error?.message ?? "修改失败")
}
}
} catch {
setServerError("网络错误,请稍后重试")
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{success ? "密码修改成功" : "修改当前账号的登录密码"}
</DialogDescription>
</DialogHeader>
{success ? (
<div className="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
使
</div>
) : (
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{serverError && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<div className="relative">
<Input
id="currentPassword"
type={showCurrent ? "text" : "password"}
autoComplete="current-password"
{...form.register("currentPassword")}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowCurrent(!showCurrent)}
>
{showCurrent ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
{form.formState.errors.currentPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.currentPassword.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<div className="relative">
<Input
id="newPassword"
type={showNew ? "text" : "password"}
autoComplete="new-password"
{...form.register("newPassword")}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowNew(!showNew)}
>
{showNew ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
{form.formState.errors.newPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.newPassword.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
{...form.register("confirmPassword")}
/>
{form.formState.errors.confirmPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.confirmPassword.message}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={submitting}
>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "提交中..." : "确认修改"}
</Button>
</DialogFooter>
</form>
)}
{success && (
<DialogFooter>
<Button onClick={() => handleOpenChange(false)}></Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -1,3 +1,4 @@
import { useState } from "react"
import { NavLink, useNavigate } from "react-router" import { NavLink, useNavigate } from "react-router"
import { import {
LayoutDashboard, LayoutDashboard,
@ -12,9 +13,11 @@ import {
AlertCircle, AlertCircle,
Shield, Shield,
BookMarked, BookMarked,
KeyRound,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/hooks/use-auth" import { useAuth } from "@/hooks/use-auth"
import { ChangePasswordDialog } from "@/components/admin/ChangePasswordDialog"
const navItems = [ const navItems = [
{ to: "/", label: "数据看板", icon: LayoutDashboard, end: true }, { to: "/", label: "数据看板", icon: LayoutDashboard, end: true },
@ -33,6 +36,7 @@ const navItems = [
export function Sidebar() { export function Sidebar() {
const { logout } = useAuth() const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
function handleLogout() { function handleLogout() {
logout() logout()
@ -40,41 +44,52 @@ export function Sidebar() {
} }
return ( return (
<aside className="flex h-screen w-60 flex-col border-r bg-sidebar-background"> <>
<div className="flex h-14 items-center border-b px-4"> <aside className="flex h-screen w-60 flex-col border-r bg-sidebar-background">
<span className="text-lg font-semibold"></span> <div className="flex h-14 items-center border-b px-4">
</div> <span className="text-lg font-semibold"></span>
</div>
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon, end }) => ( {navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
end={end} end={end}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground" ? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground", : "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
) )
} }
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
<div className="border-t p-3 space-y-1">
<button
onClick={() => setChangePasswordOpen(true)}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
> >
<Icon className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
{label}
</NavLink> </button>
))} <button
</nav> onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
>
<LogOut className="h-4 w-4" />
退
</button>
</div>
</aside>
<div className="border-t p-3"> <ChangePasswordDialog open={changePasswordOpen} onOpenChange={setChangePasswordOpen} />
<button </>
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
>
<LogOut className="h-4 w-4" />
退
</button>
</div>
</aside>
) )
} }

View File

@ -44,6 +44,24 @@ export async function fetchMe(): Promise<ApiResponse<Admin>> {
return apiClient.get("auth/me").json<ApiResponse<Admin>>() return apiClient.get("auth/me").json<ApiResponse<Admin>>()
} }
// ==================== 修改密码 ====================
/** 修改密码请求 */
export interface ChangePasswordRequest {
currentPassword: string
newPassword: string
}
/**
*
* PUT /admin/change-password
*/
export async function changePassword(
data: ChangePasswordRequest
): Promise<ApiResponse<{ success: boolean }>> {
return apiClient.put("change-password", { json: data }).json<ApiResponse<{ success: boolean }>>()
}
// ==================== 管理员管理 ==================== // ==================== 管理员管理 ====================
/** fetchAdmins 的查询参数 */ /** fetchAdmins 的查询参数 */