feat:账号设置+密码重置

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 856bdbae8d
commit 5f5eb739c2
13 changed files with 815 additions and 29 deletions
+3
View File
@@ -6,6 +6,9 @@
/.react-router/
/build/
# Vite / file-viewer 构建产物,不要提交
/dist/
# monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时
# 从 node_modules/monaco-editor/min/vs 复制生成,不要提交)
/public/monaco/vs/
@@ -0,0 +1,269 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useAuth } from "~/context/AuthContext";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
formFieldClass,
formHintClass,
formInputClass,
modalFormClass,
} from "~/features/platform/modalUi";
import { cn } from "~/lib/utils";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "./AppFormDialog";
type AccountSettingsDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type SettingsTab = "profile" | "password";
const TAB_ITEMS: Array<{ id: SettingsTab; label: string }> = [
{ id: "profile", label: "基本信息" },
{ id: "password", label: "修改密码" },
];
export function AccountSettingsDialog({
open,
onOpenChange,
}: AccountSettingsDialogProps) {
const { user, updateProfile, changePassword, logout } = useAuth();
const [tab, setTab] = useState<SettingsTab>("profile");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [savingPassword, setSavingPassword] = useState(false);
useEffect(() => {
if (!open || !user) return;
setTab("profile");
setDisplayName(user.display_name);
setEmail(user.email ?? "");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}, [open, user]);
const handleSaveProfile = async (event: React.FormEvent) => {
event.preventDefault();
const trimmedName = displayName.trim();
if (!trimmedName) {
toast.error("显示名称不能为空");
return;
}
setSavingProfile(true);
try {
await updateProfile({
display_name: trimmedName,
email: email.trim(),
});
toast.success("资料已更新");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "资料更新失败");
} finally {
setSavingProfile(false);
}
};
const handleChangePassword = async (event: React.FormEvent) => {
event.preventDefault();
if (!currentPassword) {
toast.error("请输入当前密码");
return;
}
if (newPassword.length < 8 || newPassword.length > 72) {
toast.error("新密码长度需为 8~72 字符");
return;
}
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
return;
}
if (newPassword === currentPassword) {
toast.error("新密码不能与当前密码相同");
return;
}
setSavingPassword(true);
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword,
});
toast.success("密码已修改,请重新登录");
onOpenChange(false);
await logout();
} catch (error) {
toast.error(error instanceof Error ? error.message : "密码修改失败");
} finally {
setSavingPassword(false);
}
};
const formId =
tab === "profile" ? "account-profile-form" : "account-password-form";
const saving = tab === "profile" ? savingProfile : savingPassword;
const primaryLabel =
tab === "profile"
? savingProfile
? "保存中…"
: "保存资料"
: savingPassword
? "修改中…"
: "修改密码";
return (
<AppFormDialog
open={open}
onOpenChange={onOpenChange}
eyebrow="ACCOUNT"
title="账号设置"
size="panel"
showCloseButton
footer={
<>
<Button
type="button"
variant="outline"
className={dialogSecondaryButtonClass}
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button
type="submit"
form={formId}
disabled={saving}
className={dialogPrimaryButtonClass}
>
{primaryLabel}
</Button>
</>
}
>
<div className="border-b border-line px-[22px]">
<div className="flex gap-1" role="tablist" aria-label="账号设置分区">
{TAB_ITEMS.map((item) => {
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
className={cn(
"relative -mb-px cursor-pointer border-0 border-b-2 bg-transparent px-3 py-2.5 text-[12px] font-[650] outline-none transition-colors",
active
? "border-[#2d82d4] text-[#1c5fa8]"
: "border-transparent text-[#6b7c8f] hover:text-[#3c4e62]",
)}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
);
})}
</div>
</div>
<div className={`${modalFormClass} pb-5`}>
{tab === "profile" ? (
<form
id={formId}
onSubmit={(event) => void handleSaveProfile(event)}
>
<label className={formFieldClass}>
<span></span>
<Input
className={formInputClass}
value={user?.username ?? ""}
disabled
readOnly
/>
<span className={formHintClass}></span>
</label>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="请输入显示名称"
autoComplete="name"
autoFocus
/>
</label>
<label className={formFieldClass}>
<span></span>
<Input
className={formInputClass}
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="可选"
autoComplete="email"
/>
</label>
</form>
) : (
<form
id={formId}
onSubmit={(event) => void handleChangePassword(event)}
>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
autoFocus
/>
</label>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
placeholder="8~72 字符"
/>
</label>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
</form>
)}
</div>
</AppFormDialog>
);
}
+74 -12
View File
@@ -1,5 +1,7 @@
import { ChevronRight, LayoutGrid } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ChevronRight, LayoutGrid, LogOut, Settings } from "lucide-react";
import type { AuthUser, AuthWorkspace } from "../../context/AuthContext";
import { AccountSettingsDialog } from "./AccountSettingsDialog";
type TopbarProps = {
pageTitle: string;
@@ -24,6 +26,26 @@ export function Topbar({
onSetCurrentWorkspace,
onLogout,
}: TopbarProps) {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!userMenuOpen) return;
const onPointerDown = (event: MouseEvent) => {
if (!userMenuRef.current?.contains(event.target as Node)) {
setUserMenuOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [userMenuOpen]);
const openUserMenu = () => {
onSetWorkspaceMenuOpen(false);
setUserMenuOpen((open) => !open);
};
return (
<header className="relative z-[2] flex h-[70px] min-h-[70px] items-center justify-between border-b border-[#dfe6ee] bg-white px-[22px]">
<div className="flex items-center gap-[9px]">
@@ -57,7 +79,10 @@ export function Topbar({
<button
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-line bg-white px-[11px] hover:border-[#b9cce0]"
type="button"
onClick={() => onSetWorkspaceMenuOpen(!workspaceMenuOpen)}
onClick={() => {
setUserMenuOpen(false);
onSetWorkspaceMenuOpen(!workspaceMenuOpen);
}}
>
<span className="grid size-[30px] place-items-center rounded-[7px] bg-gradient-to-br from-[#23aa73] to-[#158d5d] text-white">
<LayoutGrid size={18} />
@@ -104,10 +129,13 @@ export function Topbar({
</div>
)}
</div>
<div className="relative flex items-center gap-[9px]">
<div className="relative" ref={userMenuRef}>
<button
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-transparent bg-white px-[11px] hover:border-[#b9cce0]"
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="menu"
onClick={openUserMenu}
>
<span className="grid size-[34px] place-items-center rounded-full bg-gradient-to-br from-[#3b92ed] to-[#1869c9] text-sm font-bold text-white">
{user?.display_name?.slice(0, 1) ?? "?"}
@@ -120,18 +148,52 @@ export function Topbar({
{user?.role_code === "admin" ? "管理员" : "开发人员"}
</small>
</span>
<ChevronRight size={15} className="text-[#97a3b1]" />
</button>
<button
className="inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent px-px py-[5px] text-xs font-semibold text-[#1474d4] hover:text-[#0d58a2]"
type="button"
onClick={() => {
void onLogout();
}}
>
</button>
{userMenuOpen && (
<div
className="absolute top-[calc(100%+7px)] right-0 z-50 w-[240px] rounded-lg border border-line bg-white p-1.5 shadow-lg"
role="menu"
>
<div className="border-b border-line px-2.5 py-2.5">
<strong className="block truncate text-[12px] text-[#243a50]">
{user?.display_name ?? "未知用户"}
</strong>
<small className="mt-0.5 block truncate text-[10px] text-[#8797a7]">
{user?.username}
{user?.role_code === "admin" ? " · 管理员" : " · 开发人员"}
</small>
</div>
<button
className="mt-1 flex w-full cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-[12px] text-[#40576d] hover:bg-brand-soft"
type="button"
role="menuitem"
onClick={() => {
setUserMenuOpen(false);
setSettingsOpen(true);
}}
>
<Settings size={14} />
</button>
<button
className="flex w-full cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-[12px] text-[#c14a4a] hover:bg-[#fdf2f2]"
type="button"
role="menuitem"
onClick={() => {
setUserMenuOpen(false);
void onLogout();
}}
>
<LogOut size={14} />
</button>
</div>
)}
</div>
</div>
<AccountSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</header>
);
}
+45 -1
View File
@@ -48,6 +48,14 @@ type AuthContextValue = {
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (input: {
display_name?: string;
email?: string;
}) => Promise<void>;
changePassword: (input: {
current_password: string;
new_password: string;
}) => Promise<void>;
setCurrentWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<void>;
};
@@ -162,6 +170,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [navigate]);
const updateProfile = useCallback(async (input: {
display_name?: string;
email?: string;
}) => {
const result = await authRequest<{ user: AuthUser }>("/api/v1/auth/me", {
method: "PATCH",
body: JSON.stringify(input),
});
setUser(result.user);
}, []);
const changePassword = useCallback(async (input: {
current_password: string;
new_password: string;
}) => {
await authRequest<{ password_changed: boolean }>("/api/v1/auth/password", {
method: "POST",
body: JSON.stringify(input),
});
}, []);
const setCurrentWorkspace = useCallback((workspaceId: string) => {
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
if (!workspace) return;
@@ -193,9 +222,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loading,
login,
logout,
updateProfile,
changePassword,
setCurrentWorkspace,
refreshWorkspaces,
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]);
}), [
changePassword,
currentWorkspace,
loading,
login,
logout,
refreshWorkspaces,
setCurrentWorkspace,
updateProfile,
user,
workspaces,
]);
if (loading) {
return (
@@ -283,6 +325,8 @@ export function useApi(): WorkspaceBoundApi {
rawApi.updatePlatformEmployee(userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
resetPlatformEmployeePassword: (userId, newPassword) =>
rawApi.resetPlatformEmployeePassword(userId, newPassword),
// Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId
listPlatformRoles: () => rawApi.listPlatformRoles(),
getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode),
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
formFieldClass,
formHintClass,
formInputClass,
modalFormClass,
} from "../platform/modalUi";
type ResetPasswordDialogProps = {
open: boolean;
displayName: string;
username: string;
saving: boolean;
onSubmit: (newPassword: string) => Promise<void>;
onClose: () => void;
};
export function ResetPasswordDialog({
open,
displayName,
username,
saving,
onSubmit,
onClose,
}: ResetPasswordDialogProps) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setPassword("");
setConfirm("");
setError(null);
}, [open]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (password.length < 8 || password.length > 72) {
setError("密码长度需为 8~72 字符");
return;
}
if (password !== confirm) {
setError("两次输入的密码不一致");
return;
}
setError(null);
await onSubmit(password);
};
return (
<AppFormDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
eyebrow="SECURITY"
title="重置密码"
>
<form
className={`${modalFormClass} pb-5`}
onSubmit={(event) => void handleSubmit(event)}
>
<p className="mb-4 text-[12px] text-[#587087]">
<strong className="text-[#23384e]">{displayName}</strong>
{username}使
</p>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
type="password"
autoFocus
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
placeholder="8~72 字符"
/>
</label>
<label className={formFieldClass}>
<span>
<span className="text-danger">*</span>
</span>
<Input
className={formInputClass}
type="password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="new-password"
/>
<span className={formHintClass}>线</span>
</label>
{error ? (
<p className="mb-3 text-[11px] text-danger">{error}</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className={dialogSecondaryButtonClass}
onClick={onClose}
disabled={saving}
>
</Button>
<Button
type="submit"
className={dialogPrimaryButtonClass}
disabled={saving}
>
{saving ? "重置中…" : "确认重置"}
</Button>
</div>
</form>
</AppFormDialog>
);
}
@@ -9,6 +9,7 @@ import { Input } from "~/components/ui/input";
import { CreateUserProjectField } from "./CreateUserProjectField";
import {
formFieldClass,
formHintClass,
formInputClass,
modalFormClass,
} from "../platform/modalUi";
@@ -40,6 +41,7 @@ export function UserFormDialog({
onToggleWorkspace,
onSubmit,
onClose,
onResetPassword,
}: {
open: boolean;
editing: Employee | null;
@@ -55,6 +57,7 @@ export function UserFormDialog({
onToggleWorkspace: (workspaceId: string) => void;
onSubmit: (event: React.FormEvent) => void;
onClose: () => void;
onResetPassword?: () => void;
}) {
return (
<AppFormDialog
@@ -189,6 +192,23 @@ export function UserFormDialog({
</select>
</label>
)}
{editing && onResetPassword ? (
<div className="mb-4 rounded-md border border-[#e6edf3] bg-[#f8fafb] px-3.5 py-3">
<div className="text-[12px] font-semibold text-[#3c4e62]"></div>
<p className={`${formHintClass} mt-1.5 mb-2.5 leading-relaxed`}>
使
</p>
<Button
type="button"
variant="outline"
size="sm"
className={dialogSecondaryButtonClass}
onClick={onResetPassword}
>
</Button>
</div>
) : null}
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
<Button
variant="outline"
@@ -16,6 +16,7 @@ import {
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { UserFormDialog, type UserFormState } from "./UserFormDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { useCursorPage } from "./useCursorPage";
import {
adminEmptyClass,
@@ -61,6 +62,8 @@ export function UserManagementPage({
const [form, setForm] = useState<UserFormState>(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const [resetTarget, setResetTarget] = useState<Employee | null>(null);
const [resetSaving, setResetSaving] = useState(false);
const [createProjects, setCreateProjects] = useState<Workspace[]>([]);
const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]);
@@ -262,6 +265,23 @@ export function UserManagementPage({
}
};
const executeResetPassword = async (newPassword: string): Promise<void> => {
if (!resetTarget) return;
setResetSaving(true);
try {
await api.resetPlatformEmployeePassword(resetTarget.user_id, newPassword);
onNotify({ tone: "success", message: "密码已重置" });
setResetTarget(null);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "重置密码失败",
});
} finally {
setResetSaving(false);
}
};
const emptyMessage = useMemo(() => {
if (loading) return "正在加载用户…";
if (debouncedSearch.trim()) return "未找到匹配的用户";
@@ -379,6 +399,14 @@ export function UserManagementPage({
onToggleWorkspace={toggleCreateWorkspace}
onSubmit={(event) => void submit(event)}
onClose={() => setDialogOpen(false)}
onResetPassword={
editing
? () => {
setResetTarget(editing);
setDialogOpen(false);
}
: undefined
}
/>
<ConfirmDialog
@@ -400,6 +428,15 @@ export function UserManagementPage({
setDeleteTarget(null);
}}
/>
<ResetPasswordDialog
open={resetTarget !== null}
displayName={resetTarget?.display_name ?? ""}
username={resetTarget?.username ?? ""}
saving={resetSaving}
onSubmit={executeResetPassword}
onClose={() => setResetTarget(null)}
/>
</section>
);
}
+4
View File
@@ -267,6 +267,10 @@ export type WorkspaceBoundApi = {
deletePlatformEmployee: (
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
resetPlatformEmployeePassword: (
userId: string,
newPassword: string,
) => Promise<{ user_id: string; password_reset: boolean }>;
listPlatformRoles: () => Promise<Role[]>;
getPlatformRole: (roleCode: string) => Promise<Role>;
listPlatformPermissions: () => Promise<PlatformPermission[]>;
@@ -58,3 +58,13 @@ export async function deletePlatformEmployee(
);
}
export async function resetPlatformEmployeePassword(
userId: string,
newPassword: string,
): Promise<{ user_id: string; password_reset: boolean }> {
return apiRequest(
`/api/v1/platform/employees/${userId}/reset-password`,
{ method: "POST", body: JSON.stringify({ new_password: newPassword }) },
);
}