origin/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx

254 lines
7.4 KiB
TypeScript
Executable File

import { useClickOutside } from "@web/src/hooks/useClickOutside";
import { useAuth } from "@web/src/providers/auth-provider";
import { motion, AnimatePresence } from "framer-motion";
import React, {
useState,
useRef,
useCallback,
useMemo,
createContext,
} from "react";
import { Avatar } from "@web/src/components/common/element/Avatar";
import {
UserOutlined,
SettingOutlined,
LogoutOutlined,
} from "@ant-design/icons";
import { FormInstance, Spin } from "antd";
import { useNavigate } from "react-router-dom";
import { MenuItemType } from "./types";
import { RolePerms } from "@nice/common";
import { useForm } from "antd/es/form/Form";
import UserEditModal from "./UserEditModal";
const menuVariants = {
hidden: { opacity: 0, scale: 0.95, y: -10 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
},
},
exit: {
opacity: 0,
scale: 0.95,
y: -10,
transition: {
duration: 0.2,
},
},
};
export const UserEditorContext = createContext<{
domainId: string;
setDomainId: React.Dispatch<React.SetStateAction<string>>;
modalOpen: boolean;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
form: FormInstance<any>;
formLoading: boolean;
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
}>({
modalOpen: false,
domainId: undefined,
setDomainId: undefined,
setModalOpen: undefined,
form: undefined,
formLoading: undefined,
setFormLoading: undefined,
});
export function UserMenu() {
const [form] = useForm();
const [formLoading, setFormLoading] = useState<boolean>();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { user, logout, isLoading, hasSomePermissions } = useAuth();
const navigate = useNavigate();
useClickOutside(menuRef, () => setShowMenu(false));
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [domainId, setDomainId] = useState<string>();
const toggleMenu = useCallback(() => {
setShowMenu((prev) => !prev);
}, []);
const canManageAnyStaff = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
}, [user]);
const menuItems: MenuItemType[] = useMemo(
() =>
[
{
icon: <UserOutlined className="text-lg" />,
label: "个人信息",
action: () => {
setModalOpen(true);
},
},
canManageAnyStaff && {
icon: <SettingOutlined className="text-lg" />,
label: "设置",
action: () => {
navigate("/admin/staff");
},
},
{
icon: <LogoutOutlined className="text-lg" />,
label: "注销",
action: () => logout(),
},
].filter(Boolean),
[logout]
);
const handleMenuItemClick = useCallback((action: () => void) => {
action();
setShowMenu(false);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center w-10 h-10">
<Spin size="small" />
</div>
);
}
return (
<UserEditorContext.Provider
value={{
formLoading,
setFormLoading,
form,
domainId,
modalOpen,
setDomainId,
setModalOpen,
}}>
<div ref={menuRef} className="relative">
<motion.button
aria-label="用户菜单"
aria-haspopup="true"
aria-expanded={showMenu}
aria-controls="user-menu"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={toggleMenu}
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
{/* Avatar 容器,相对定位 */}
<div className="relative">
<Avatar
src={user?.avatar}
name={user?.showname || user?.username}
size={40}
className="ring-2 ring-white hover:ring-[#00538E]/90
transition-all duration-200 ease-in-out shadow-md
hover:shadow-lg focus:outline-none
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
focus:ring-offset-white "
/>
{/* 小绿点 */}
<span
className="absolute bottom-0 right-0 h-3 w-3
rounded-full bg-emerald-500 ring-2 ring-white
shadow-sm transition-transform duration-200
ease-in-out hover:scale-110"
aria-hidden="true"
/>
</div>
{/* 用户信息,显示在 Avatar 右侧 */}
<div className="flex flex-col space-y-0.5 ml-3 items-start">
<span className="text-base text-primary flex items-center gap-1.5">
{user?.showname || user?.username}
</span>
</div>
</motion.button>
<AnimatePresence>
{showMenu && (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={menuVariants}
role="menu"
id="user-menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
style={{ zIndex: 100 }}
className="absolute right-0 mt-3 w-64 origin-top-right
bg-white rounded-xl overflow-hidden shadow-lg
border border-[#E5EDF5]">
{/* User Profile Section */}
<div
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
border-b border-[#E5EDF5] ">
<div className="flex items-center space-x-4">
<Avatar
src={user?.avatar}
name={user?.showname || user?.username}
size={40}
className="ring-2 ring-white shadow-sm"
/>
<div className="flex flex-col space-y-0.5">
<span className="text-sm font-semibold text-[#00538E]">
{user?.showname || user?.username}
</span>
<span className="text-xs text-[#718096] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
线
</span>
</div>
</div>
</div>
{/* Menu Items */}
<div className="p-2">
{menuItems.map((item, index) => (
<button
key={index}
role="menuitem"
tabIndex={showMenu ? 0 : -1}
onClick={(e) => {
e.stopPropagation();
handleMenuItemClick(item.action);
}}
className={`flex items-center gap-3 w-full px-4 py-3
text-sm font-medium rounded-lg transition-all
focus:outline-none
focus:ring-2 focus:ring-[#00538E]/20
group relative overflow-hidden
active:scale-[0.99]
${
item.label === "注销"
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
}`}>
<span
className={`w-5 h-5 flex items-center justify-center
transition-all duration-200 ease-in-out
group-hover:scale-110 group-hover:rotate-6
group-hover:translate-x-0.5 ${
item.label === "注销"
? "group-hover:text-red-600"
: "group-hover:text-[#003F6A]"
}`}>
{item.icon}
</span>
<span>{item.label}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<UserEditModal></UserEditModal>
</UserEditorContext.Provider>
);
}