Merge origin/develop into feature/a-card-operations
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ function ConfirmDialog({
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="rounded-[11px] border-[#dce4eb] bg-white">
|
||||
<AlertDialogContent className="rounded-[11px] border border-[#dce4eb] bg-white ring-0">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[#1c2d42]">{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#56687c]">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 = {
|
||||
activePage: string;
|
||||
pageTitle: string;
|
||||
apiOnline: boolean;
|
||||
user: AuthUser | null;
|
||||
currentWorkspace: AuthWorkspace;
|
||||
@@ -14,7 +16,7 @@ type TopbarProps = {
|
||||
};
|
||||
|
||||
export function Topbar({
|
||||
activePage,
|
||||
pageTitle,
|
||||
apiOnline,
|
||||
user,
|
||||
currentWorkspace,
|
||||
@@ -24,18 +26,30 @@ 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);
|
||||
};
|
||||
const roleLabel = user?.is_system_admin || user?.role_code === "admin"
|
||||
? "管理员"
|
||||
: ["business", "business_team", "biz"].includes(user?.role_code ?? "")
|
||||
? "业务团队"
|
||||
: "模型团队";
|
||||
const pageTitles: Record<string, string> = {
|
||||
home: "工作台",
|
||||
scripts: "构建脚本",
|
||||
schedules: "调度配置",
|
||||
operations: "A卡模型运维",
|
||||
system: "系统管理",
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative z-[2] flex h-[70px] min-h-[70px] items-center justify-between border-b border-[#dfe6ee] bg-white px-[22px]">
|
||||
@@ -44,14 +58,14 @@ export function Topbar({
|
||||
className="grid h-[31px] w-[31px] rotate-180 cursor-pointer place-items-center rounded-[7px] border border-transparent bg-white text-[#7b8999] hover:border-[#b9c9da] hover:bg-[#f7faff]"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight size={19} />
|
||||
<ChevronRight size={19} className="text-[#748497]"/>
|
||||
</button>
|
||||
<div>
|
||||
<span className="text-[10px] font-[650] uppercase tracking-[0.08em] text-[#94a2b3]">
|
||||
开发工作区
|
||||
</span>
|
||||
<h1 className="m-0 mt-px text-[18px] leading-[1.1] text-[#14243a]">
|
||||
{pageTitles[activePage] || "工作台"}
|
||||
{pageTitle}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,7 +84,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} />
|
||||
@@ -81,10 +98,10 @@ export function Topbar({
|
||||
{currentWorkspace.workspace_name}
|
||||
</strong>
|
||||
</span>
|
||||
<ChevronRight size={15} />
|
||||
<ChevronRight size={15} className="text-[#748497]"/>
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="absolute top-[calc(100%+7px)] right-0 z-50 w-[260px] rounded-lg border border-line bg-white p-1.5 shadow-lg">
|
||||
<div className="absolute top-[calc(100%+7px)] right-0 z-50 max-h-[min(300px,calc(100vh-90px))] w-[260px] overflow-y-auto rounded-lg border border-line bg-white p-1.5 shadow-lg">
|
||||
{workspaces.map((workspace) => {
|
||||
const isSelected =
|
||||
workspace.workspace_id === currentWorkspace.workspace_id;
|
||||
@@ -117,10 +134,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]"
|
||||
className="flex h-[46px] cursor-pointer items-center gap-[9px] rounded-lg border border-line 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) ?? "?"}
|
||||
@@ -133,18 +153,53 @@ export function Topbar({
|
||||
{roleLabel}
|
||||
</small>
|
||||
</span>
|
||||
{/* <ChevronRight size={15} className="text-[#97a3b1]" /> */}
|
||||
<ChevronRight size={15} className="text-[#748497]" />
|
||||
</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-[200px] 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user