2026-05-20 14:34:41 +08:00

273 lines
9.3 KiB
JavaScript

import ExpandMore from "@mui/icons-material/ExpandMore";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import LockReset from "@mui/icons-material/LockReset";
import Logout from "@mui/icons-material/Logout";
import Menu from "@mui/icons-material/Menu";
import MenuOpen from "@mui/icons-material/MenuOpen";
import Person from "@mui/icons-material/Person";
import Refresh from "@mui/icons-material/Refresh";
import Search from "@mui/icons-material/Search";
import MuiMenu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { lazy, Suspense, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
import { changePassword } from "@/features/auth/api";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog })));
const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefresh, onToggleSidebar }) {
const [searchOpen, setSearchOpen] = useState(false);
const [searchMounted, setSearchMounted] = useState(false);
const [userMenuAnchor, setUserMenuAnchor] = useState(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [passwordError, setPasswordError] = useState("");
const [changingPassword, setChangingPassword] = useState(false);
const navigate = useNavigate();
const { logout, user } = useAuth();
const appScope = useAppScope();
const { showToast } = useToast();
const { setTimeZone, timeZone, timeZoneOptions } = useTimeZone();
const ToggleIcon = isSidebarCollapsed ? MenuOpen : Menu;
const userMenuOpen = Boolean(userMenuAnchor);
useEffect(() => {
if (searchOpen) {
setSearchMounted(true);
return undefined;
}
const timeout = window.setTimeout(() => setSearchMounted(false), 280);
return () => window.clearTimeout(timeout);
}, [searchOpen]);
const openSearch = () => {
setSearchMounted(true);
setSearchOpen(true);
};
const closeUserMenu = () => {
setUserMenuAnchor(null);
};
const openPasswordDialog = () => {
closeUserMenu();
setPasswordForm(emptyPasswordForm);
setPasswordError("");
setPasswordDialogOpen(true);
};
const closePasswordDialog = () => {
if (changingPassword) {
return;
}
setPasswordDialogOpen(false);
setPasswordError("");
};
const handleLogout = async () => {
closeUserMenu();
await logout();
navigate("/login", { replace: true });
};
const handlePasswordFieldChange = (field) => (event) => {
setPasswordForm((current) => ({ ...current, [field]: event.target.value }));
if (passwordError) {
setPasswordError("");
}
};
const handlePasswordSubmit = async (event) => {
event.preventDefault();
const { oldPassword, newPassword, confirmPassword } = passwordForm;
if (!oldPassword || !newPassword || !confirmPassword) {
setPasswordError("请填写原密码、新密码和确认密码");
return;
}
if (newPassword.length < 6) {
setPasswordError("新密码至少 6 位");
return;
}
if (newPassword.length > 72) {
setPasswordError("新密码不能超过 72 位");
return;
}
if (newPassword !== confirmPassword) {
setPasswordError("两次输入的新密码不一致");
return;
}
setChangingPassword(true);
try {
await changePassword({ oldPassword, newPassword });
showToast("密码已修改,请重新登录", "success");
await logout();
navigate("/login", { replace: true });
} catch (err) {
setPasswordError(err.message || "修改密码失败");
} finally {
setChangingPassword(false);
}
};
const handleSearchSelect = (result) => {
if (result?.target) {
navigate(result.target);
}
};
const refresh = () => {
onRefresh();
showToast("已刷新当前页面", "success");
};
return (
<header className="header">
<div className="brand">
<div className="brand-mark">
<Inventory2Outlined fontSize="small" />
</div>
<div className="brand-text">
<span className="brand-title">HYApp 管理平台</span>
<span className="brand-meta">Admin Platform</span>
</div>
</div>
<div className="header-tools">
<div className="header-left-tools">
<IconButton label={isSidebarCollapsed ? "展开菜单" : "收起菜单"} onClick={onToggleSidebar}>
<ToggleIcon fontSize="small" />
</IconButton>
<div className="header-location">{activeLabel}</div>
</div>
<div className="header-right-tools">
<TextField
className="app-switch"
disabled={appScope.loading || !appScope.apps.length}
select
size="small"
value={appScope.appCode}
onChange={(event) => appScope.setAppCode(event.target.value)}
>
{appScope.apps.length ? (
appScope.apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))
) : (
<MenuItem value={appScope.appCode}>{appScope.appCode}</MenuItem>
)}
</TextField>
<TextField
className="timezone-switch"
select
size="small"
slotProps={{ htmlInput: { "aria-label": "显示时区" } }}
value={timeZone}
onChange={(event) => setTimeZone(event.target.value)}
>
{timeZoneOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<button className="search-trigger" type="button" onClick={openSearch}>
<Search fontSize="small" />
<span>搜索菜单用户角色...</span>
</button>
<IconButton label="刷新" onClick={refresh}>
<Refresh fontSize="small" />
</IconButton>
<button className="user-pill" type="button" onClick={(event) => setUserMenuAnchor(event.currentTarget)}>
<Person fontSize="small" />
<span>{user?.name || user?.account || "admin"}</span>
<ExpandMore fontSize="inherit" />
</button>
<MuiMenu
anchorEl={userMenuAnchor}
className="user-menu"
onClose={closeUserMenu}
open={userMenuOpen}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
>
<MenuItem className="user-menu__item" onClick={openPasswordDialog}>
<LockReset fontSize="small" />
修改密码
</MenuItem>
<MenuItem className="user-menu__item" onClick={handleLogout}>
<Logout fontSize="small" />
退出
</MenuItem>
</MuiMenu>
</div>
</div>
{searchMounted ? (
<Suspense fallback={null}>
<SearchDialog menuItems={menuItems} open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={handleSearchSelect} />
</Suspense>
) : null}
<AdminFormDialog
loading={changingPassword}
onClose={closePasswordDialog}
onSubmit={handlePasswordSubmit}
open={passwordDialogOpen}
size="narrow"
submitDisabled={
changingPassword ||
!passwordForm.oldPassword ||
!passwordForm.newPassword ||
!passwordForm.confirmPassword
}
submitLabel="确认修改"
title="修改密码"
>
<TextField
autoComplete="current-password"
autoFocus
disabled={changingPassword}
error={Boolean(passwordError)}
helperText={passwordError || "修改成功后将自动退出登录"}
label="原密码"
onChange={handlePasswordFieldChange("oldPassword")}
required
type="password"
value={passwordForm.oldPassword}
/>
<TextField
autoComplete="new-password"
disabled={changingPassword}
label="新密码"
onChange={handlePasswordFieldChange("newPassword")}
required
type="password"
value={passwordForm.newPassword}
/>
<TextField
autoComplete="new-password"
disabled={changingPassword}
label="确认新密码"
onChange={handlePasswordFieldChange("confirmPassword")}
required
type="password"
value={passwordForm.confirmPassword}
/>
</AdminFormDialog>
</header>
);
}