260 lines
7.6 KiB
TypeScript
Executable File
260 lines
7.6 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 "../../../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 "./user-edit-modal";
|
|
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: <QuestionCircleOutlined className="text-lg" />,
|
|
// label: '帮助',
|
|
// action: () => { },
|
|
// },
|
|
{
|
|
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-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: 1000 }}
|
|
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="z-50 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>
|
|
);
|
|
}
|