214 lines
6.2 KiB
TypeScript
214 lines
6.2 KiB
TypeScript
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
|
import { useAuth } from "@web/src/providers/auth-provider";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useState, useRef, useCallback, useMemo } from "react";
|
|
import { Avatar } from "../../common/element/Avatar";
|
|
import {
|
|
UserOutlined,
|
|
SettingOutlined,
|
|
QuestionCircleOutlined,
|
|
LogoutOutlined,
|
|
} from "@ant-design/icons";
|
|
import { Spin } from "antd";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { MenuItemType } from "./types";
|
|
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 function UserMenu() {
|
|
const [showMenu, setShowMenu] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const { user, logout, isLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
useClickOutside(menuRef, () => setShowMenu(false));
|
|
|
|
const toggleMenu = useCallback(() => {
|
|
setShowMenu((prev) => !prev);
|
|
}, []);
|
|
|
|
const menuItems: MenuItemType[] = useMemo(
|
|
() => [
|
|
{
|
|
icon: <UserOutlined className="text-lg" />,
|
|
label: "个人信息",
|
|
action: () => {},
|
|
},
|
|
{
|
|
icon: <SettingOutlined className="text-lg" />,
|
|
label: "设置",
|
|
action: () => {
|
|
navigate("/admin/staff");
|
|
},
|
|
},
|
|
// {
|
|
// icon: <QuestionCircleOutlined className="text-lg" />,
|
|
// label: '帮助',
|
|
// action: () => { },
|
|
// },
|
|
{
|
|
icon: <LogoutOutlined className="text-lg" />,
|
|
label: "注销",
|
|
action: () => logout(),
|
|
},
|
|
],
|
|
[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 (
|
|
<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-sm font-semibold text-white">
|
|
{user?.showname || user?.username}
|
|
</span>
|
|
<span className="text-xs text-white flex items-center gap-1.5">
|
|
{user?.department?.name}
|
|
</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>
|
|
);
|
|
}
|