298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import {
|
|
Bell,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronsUpDown,
|
|
Folder,
|
|
FolderOpen,
|
|
Settings,
|
|
User,
|
|
Wrench,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
|
import { SettingsDialog } from "@/components/workspace/settings";
|
|
import { useTheme } from "@/contexts/ThemeContext";
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { useUnreadCount } from "@/core/notifications";
|
|
import {
|
|
defaultExpandedIds,
|
|
filterMenuItemsByRole,
|
|
sidebarMenuItems,
|
|
type MenuItem,
|
|
} from "@/core/page-layout/sidebar-menu";
|
|
|
|
type PageSidebarProps = {
|
|
collapsed: boolean;
|
|
onToggleCollapsed: () => void;
|
|
};
|
|
|
|
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
|
|
|
export function PageSidebar({
|
|
collapsed,
|
|
onToggleCollapsed,
|
|
}: PageSidebarProps) {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === "dark";
|
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
|
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
|
|
const { t } = useI18n();
|
|
const { data: unreadCount = 0 } = useUnreadCount();
|
|
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [settingsDefaultSection, setSettingsDefaultSection] =
|
|
useState<SettingsSection>("appearance");
|
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const navigate = useNavigate();
|
|
const { pathname } = useLocation();
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const openSettings = (section: SettingsSection) => {
|
|
setSettingsDefaultSection(section);
|
|
setSettingsOpen(true);
|
|
};
|
|
|
|
const iconButtonClass = isDark
|
|
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
|
|
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
|
const panelClass = isDark
|
|
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
|
|
: "ml-4 space-y-1 border-l border-red-100 pl-4";
|
|
const labelClass = (compact: boolean, small = false) =>
|
|
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
|
|
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
|
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
|
const menuButtonClass = (isActive: boolean, depth = 0) =>
|
|
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
|
|
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
|
} ${
|
|
isActive
|
|
? isDark
|
|
? "bg-blue-500/15 text-cyan-300 font-semibold"
|
|
: "bg-red-50 text-red-600 font-semibold"
|
|
: isDark
|
|
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
|
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
|
}`;
|
|
const footerButtonClass = menuButtonClass(false, 0);
|
|
|
|
const toggleExpanded = (id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const renderLeaf = (item: MenuItem, depth = 0) => {
|
|
const Icon = item.icon;
|
|
const isActive = !!item.path && pathname === item.path;
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
title={collapsed && depth === 0 ? item.label : undefined}
|
|
onClick={() => item.path && navigate(item.path)}
|
|
className={menuButtonClass(isActive, depth)}
|
|
>
|
|
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
|
{(!collapsed || depth > 0) && (
|
|
<span className={labelClass(false, depth > 0)}>
|
|
{item.label}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const renderBranch = (item: MenuItem, depth = 0) => {
|
|
const expanded = expandedIds.has(item.id);
|
|
const isTopLevel = depth === 0;
|
|
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
|
|
|
|
return (
|
|
<div key={item.id} className="space-y-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (collapsed && isTopLevel) {
|
|
onToggleCollapsed();
|
|
return;
|
|
}
|
|
toggleExpanded(item.id);
|
|
}}
|
|
title={collapsed && isTopLevel ? item.label : undefined}
|
|
className={menuButtonClass(false, depth)}
|
|
>
|
|
<Icon
|
|
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
|
expanded && depth > 0 ? "scale-105" : ""
|
|
}`}
|
|
/>
|
|
{(!collapsed || !isTopLevel) && (
|
|
<span className={labelClass(false, depth > 0)}>
|
|
{item.label}
|
|
</span>
|
|
)}
|
|
{(!collapsed || !isTopLevel) && (
|
|
<ChevronRight
|
|
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
|
|
{(!collapsed || !isTopLevel) && item.children && (
|
|
<div
|
|
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
|
|
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
|
|
}`}
|
|
>
|
|
{item.children.map((child) =>
|
|
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SettingsDialog
|
|
open={settingsOpen}
|
|
onOpenChange={setSettingsOpen}
|
|
defaultSection={settingsDefaultSection}
|
|
/>
|
|
<NotificationDialog
|
|
open={notificationOpen}
|
|
onOpenChange={setNotificationOpen}
|
|
/>
|
|
<aside
|
|
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
|
isDark
|
|
? "border-r border-blue-800/30 bg-slate-900"
|
|
: "border-r border-red-100 bg-white"
|
|
} ${collapsed ? "w-16" : "w-64"}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onToggleCollapsed}
|
|
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
|
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
|
|
isDark
|
|
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
|
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
|
|
}`}
|
|
>
|
|
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
|
|
</button>
|
|
|
|
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
|
{visibleMenuItems.map((item) =>
|
|
item.children ? renderBranch(item) : renderLeaf(item),
|
|
)}
|
|
</nav>
|
|
|
|
<div
|
|
className={`mt-auto shrink-0 theme-transition ${
|
|
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
|
|
} ${collapsed ? "p-2" : "p-4"}`}
|
|
>
|
|
{mounted ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
title={collapsed ? t.workspace.settingsAndMore : undefined}
|
|
className={footerButtonClass}
|
|
>
|
|
<Settings className="h-5 w-5 flex-shrink-0" />
|
|
{!collapsed && (
|
|
<>
|
|
<span className={labelClass(false)}>
|
|
{t.workspace.settingsAndMore}
|
|
</span>
|
|
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align={collapsed ? "start" : "end"}
|
|
side={collapsed ? "right" : "top"}
|
|
sideOffset={4}
|
|
className="min-w-56 rounded-lg"
|
|
>
|
|
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
|
<Bell className="size-4" />
|
|
{t.notifications.title}
|
|
{unreadCount > 0 && (
|
|
<span
|
|
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
|
|
isDark ? "bg-cyan-500" : "bg-red-500"
|
|
}`}
|
|
>
|
|
{unreadCount > 99 ? "99+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => openSettings("tools")}>
|
|
<Wrench className="size-4" />
|
|
{t.settings.sections.tools}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => openSettings("account")}>
|
|
<User className="size-4" />
|
|
个人信息
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={`${footerButtonClass} pointer-events-none`}
|
|
>
|
|
<Settings className="h-5 w-5 flex-shrink-0" />
|
|
{!collapsed && (
|
|
<span className={labelClass(false)}>
|
|
{t.workspace.settingsAndMore}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
|
|
isDark ? "bg-blue-500/10" : "bg-red-500/5"
|
|
}`}
|
|
/>
|
|
<div
|
|
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
|
|
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
|
|
}`}
|
|
/>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|