572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useNavigate, useLocation } from "react-router-dom";
|
||
import {
|
||
Bell,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsUpDown,
|
||
Folder,
|
||
FolderOpen,
|
||
Gauge,
|
||
ListTree,
|
||
MousePointerClick,
|
||
Settings,
|
||
ShieldAlert,
|
||
ShieldCheck,
|
||
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 { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||
|
||
/** 敏感词管理仅对用户名为 lqq 的账号可见(用户名 = 设置→个人信息页展示名)。 */
|
||
const SENSITIVE_WORDS_USERNAME = "lqq";
|
||
const canManageSensitiveWords = () =>
|
||
getAccountDisplayName().toLowerCase() === SENSITIVE_WORDS_USERNAME;
|
||
import { isWujieEnv } from "@/core/embed/wujie";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { useUnreadCount } from "@/core/notifications";
|
||
import {
|
||
defaultExpandedIds,
|
||
getActiveThirdLevelItems,
|
||
menuItemContainsPath,
|
||
menuItemMatchesPath,
|
||
type MenuItem,
|
||
} from "@/core/page-layout/sidebar-menu";
|
||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||
|
||
import "@/components/page-sdebar.css";
|
||
|
||
type PageSidebarProps = {
|
||
collapsed: boolean;
|
||
onToggleCollapsed: () => void;
|
||
};
|
||
|
||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||
|
||
const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
|
||
menuItemContainsPath(item, pathname);
|
||
|
||
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||
if (!items?.length) {
|
||
return null;
|
||
}
|
||
for (const child of items) {
|
||
if (child.path) {
|
||
return child.path;
|
||
}
|
||
const nested = findFirstRoutePath(child.children);
|
||
if (nested) {
|
||
return nested;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/**
|
||
* 浅色侧边栏入口。
|
||
*
|
||
* - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。
|
||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||
*/
|
||
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
||
if (isWujieEnv()) {
|
||
return (
|
||
<LegacyAccordionSidebar
|
||
collapsed={collapsed}
|
||
onToggleCollapsed={onToggleCollapsed}
|
||
/>
|
||
);
|
||
}
|
||
return <SecondaryNavSidebar />;
|
||
}
|
||
|
||
/**
|
||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||
*/
|
||
function SecondaryNavSidebar() {
|
||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||
const { t } = useI18n();
|
||
const { data: unreadCount = 0 } = useUnreadCount();
|
||
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);
|
||
};
|
||
|
||
// 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。
|
||
const activeTopItem =
|
||
visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ??
|
||
visibleMenuItems[0];
|
||
const secondaryItems = activeTopItem?.children ?? [];
|
||
|
||
const isItemActive = (item: MenuItem) =>
|
||
item.path || item.aliasPaths?.length
|
||
? menuItemMatchesPath(item, pathname)
|
||
: menuItemContainsPath(item, pathname);
|
||
|
||
const handleItemClick = (item: MenuItem) => {
|
||
const path = item.path ?? findFirstRoutePath(item.children);
|
||
if (path) {
|
||
navigate(path);
|
||
}
|
||
};
|
||
|
||
// 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。
|
||
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||
const expanded = thirdItems.length > 0;
|
||
|
||
return (
|
||
<>
|
||
<SettingsDialog
|
||
open={settingsOpen}
|
||
onOpenChange={setSettingsOpen}
|
||
defaultSection={settingsDefaultSection}
|
||
/>
|
||
<NotificationDialog
|
||
open={notificationOpen}
|
||
onOpenChange={setNotificationOpen}
|
||
/>
|
||
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
|
||
{/* 一级列:二级图标卡片 + 底部设置 */}
|
||
<div className="light-sidebar__primary">
|
||
<nav className="light-sidebar__list" aria-label="二级菜单">
|
||
{secondaryItems.map((item) => {
|
||
const Icon = item.icon;
|
||
const active = isItemActive(item);
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
title={item.label}
|
||
onClick={() => handleItemClick(item)}
|
||
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
|
||
>
|
||
<Icon className="light-sidebar-item__icon h-7 w-7" />
|
||
<span className="light-sidebar-item__label">{item.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className="light-sidebar__footer">
|
||
{mounted ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
title={t.workspace.settingsAndMore}
|
||
className="light-sidebar-item"
|
||
>
|
||
<Settings className="light-sidebar-item__icon h-7 w-7" />
|
||
<span className="light-sidebar-item__label">设置</span>
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
side="right"
|
||
sideOffset={8}
|
||
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 bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
|
||
{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>
|
||
{isAdmin && (
|
||
<DropdownMenuItem
|
||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||
>
|
||
<ListTree className="size-4" />
|
||
菜单管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{isAdmin && (
|
||
<DropdownMenuItem
|
||
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
||
>
|
||
<MousePointerClick className="size-4" />
|
||
按钮管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{isAdmin && (
|
||
<DropdownMenuItem
|
||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||
>
|
||
<ShieldCheck className="size-4" />
|
||
白名单管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{isAdmin && (
|
||
<DropdownMenuItem
|
||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||
>
|
||
<Gauge className="size-4" />
|
||
模型并发限制
|
||
</DropdownMenuItem>
|
||
)}
|
||
{canManageSensitiveWords() && (
|
||
<DropdownMenuItem
|
||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||
>
|
||
<ShieldAlert className="size-4" />
|
||
敏感词管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
|
||
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
|
||
<nav className="light-sidebar-subnav" aria-label="三级菜单">
|
||
{thirdItems.map((child) => {
|
||
const ChildIcon = child.icon;
|
||
const childActive = menuItemMatchesPath(child, pathname)
|
||
|| (child.children?.length ? menuItemContainsPath(child, pathname) : false);
|
||
return (
|
||
<button
|
||
key={child.id}
|
||
type="button"
|
||
title={child.label}
|
||
onClick={() => handleItemClick(child)}
|
||
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
|
||
>
|
||
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
|
||
<span className="light-sidebar-subitem__label">{child.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
</aside>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。
|
||
*/
|
||
function LegacyAccordionSidebar({
|
||
collapsed,
|
||
onToggleCollapsed,
|
||
}: PageSidebarProps) {
|
||
const { theme } = useTheme();
|
||
const isDark = theme === "dark";
|
||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||
const visibleMenuItems = useSidebarMenuItems(!!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 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) =>
|
||
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
|
||
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 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 = menuItemMatchesPath(item, pathname);
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
title={collapsed && depth === 0 ? item.label : undefined}
|
||
onClick={() => item.path && navigate(item.path)}
|
||
className={menuButtonClass(isActive, depth)}
|
||
>
|
||
{isActive && (
|
||
<span
|
||
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
|
||
isDark ? "bg-cyan-300" : "bg-red-600"
|
||
}`}
|
||
/>
|
||
)}
|
||
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
|
||
{(!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-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
|
||
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>
|
||
{isAdmin && (
|
||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||
<ListTree className="size-4" />
|
||
菜单管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{isAdmin && (
|
||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
||
<MousePointerClick className="size-4" />
|
||
按钮管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{isAdmin && (
|
||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||
<ShieldCheck className="size-4" />
|
||
白名单管理
|
||
</DropdownMenuItem>
|
||
)}
|
||
{canManageSensitiveWords() && (
|
||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||
<ShieldAlert 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>
|
||
</>
|
||
);
|
||
}
|