From 7ea082db2861222b728a0ca96e4cd2687e0511de Mon Sep 17 00:00:00 2001 From: hzw Date: Wed, 14 Oct 2026 16:38:28 +0800 Subject: [PATCH 1/5] fix(frontend): update sidebar account menu and hover navigation --- frontend/app/[locale]/layout.client.tsx | 30 +- frontend/components/auth/avatarDropdown.tsx | 402 ++++++++++++------ .../navigation/NavigationRailLabel.tsx | 23 + .../navigation/NotificationBell.tsx | 59 ++- .../components/navigation/SideNavigation.tsx | 116 ++++- frontend/hooks/useAccountMonitoring.ts | 30 ++ frontend/public/locales/en/common.json | 7 +- frontend/public/locales/zh/common.json | 7 +- frontend/tests/component/accountMenu.test.tsx | 210 +++++++++ .../component/accountMonitoring.test.tsx | 104 +++++ .../component/sharedShellLayout.test.tsx | 100 +++++ .../tests/component/sidebarHover.test.tsx | 138 ++++++ test-e2e/cases/NAV-MENU-D1-001/case.yaml | 3 +- test-e2e/cases/NAV-MENU-D1-001/test.test.tsx | 5 +- test-e2e/cases/NAV-MENU-D4-001/case.yaml | 25 +- test-e2e/cases/NAV-MENU-D4-001/execution.yaml | 4 + test-e2e/cases/NAV-MENU-D4-001/test.spec.ts | 142 ++++++- .../bugs/NAV-USER-PREFETCH-20261014.yaml | 31 ++ .../requirements/NAV-HOVER-20261014.yaml | 26 ++ .../requirements/NAV-USER-MENU-20261014.yaml | 31 ++ .../features/NAV-MENU-VISIBILITY/feature.yaml | 6 + 21 files changed, 1284 insertions(+), 215 deletions(-) create mode 100644 frontend/components/navigation/NavigationRailLabel.tsx create mode 100644 frontend/hooks/useAccountMonitoring.ts create mode 100644 frontend/tests/component/accountMenu.test.tsx create mode 100644 frontend/tests/component/accountMonitoring.test.tsx create mode 100644 frontend/tests/component/sharedShellLayout.test.tsx create mode 100644 frontend/tests/component/sidebarHover.test.tsx create mode 100644 test-e2e/changes/bugs/NAV-USER-PREFETCH-20261014.yaml create mode 100644 test-e2e/changes/requirements/NAV-HOVER-20261014.yaml create mode 100644 test-e2e/changes/requirements/NAV-USER-MENU-20261014.yaml diff --git a/frontend/app/[locale]/layout.client.tsx b/frontend/app/[locale]/layout.client.tsx index d79611afbe..68270859ce 100644 --- a/frontend/app/[locale]/layout.client.tsx +++ b/frontend/app/[locale]/layout.client.tsx @@ -3,10 +3,8 @@ import { type CSSProperties, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { Layout, Spin } from "antd"; -import { TopNavbar } from "@/components/navigation/TopNavbar"; import { SideNavigation } from "@/components/navigation/SideNavigation"; -import { FooterLayout } from "@/components/navigation/FooterLayout"; -import { FOOTER_CONFIG, SIDER_CONFIG } from "@/const/layoutConstants"; +import { SIDER_CONFIG } from "@/const/layoutConstants"; import { AuthDialogs } from "@/components/auth/AuthDialogs"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; @@ -14,7 +12,7 @@ import { getEffectiveRoutePath } from "@/lib/auth"; import { QuotaWarningMonitor } from "@/components/quota/QuotaWarningMonitor"; import { MemoryEmbeddingMonitor } from "@/components/memory/MemoryEmbeddingMonitor"; -const { Sider, Content, Footer } = Layout; +const { Sider, Content } = Layout; export function ClientLayout({ children }: { children: ReactNode }) { const pathname = usePathname(); @@ -22,14 +20,9 @@ export function ClientLayout({ children }: { children: ReactNode }) { const { isSpeedMode } = useDeployment(); const effectivePath = getEffectiveRoutePath(pathname); - const isNewChatPage = effectivePath?.startsWith("/newchat") ?? false; const isHomePage = effectivePath === "/"; const isOAuthCompletePage = effectivePath === "/oauth/complete"; const isSharePage = effectivePath.startsWith("/share/"); - const isAgentConfigPage = - effectivePath === "/agents" || effectivePath.startsWith("/agents/"); - - const footerReservedHeight = parseInt(FOOTER_CONFIG.RESERVED_HEIGHT); const layoutStyle: CSSProperties = { height: "100vh", @@ -50,19 +43,10 @@ export function ClientLayout({ children }: { children: ReactNode }) { left: 0, width: SIDER_CONFIG.WIDTH, backgroundColor: "#f0f0f0", - overflow: "hidden", + overflow: "visible", zIndex: 30, }; - const footerStyle: CSSProperties = { - textAlign: "center", - height: footerReservedHeight, - lineHeight: footerReservedHeight, - padding: 0, - flexShrink: 0, - backgroundColor: "#fff", - }; - const contentStyle: CSSProperties = { flex: 1, minHeight: 0, @@ -110,8 +94,6 @@ export function ClientLayout({ children }: { children: ReactNode }) { marginLeft: `${SIDER_CONFIG.WIDTH}px`, }} > - {!isAgentConfigPage && !isNewChatPage && } - {isHomePage || isOAuthCompletePage || isSharePage || isAuthorized ? ( children @@ -121,12 +103,6 @@ export function ClientLayout({ children }: { children: ReactNode }) { )} - - {!isAgentConfigPage && !isNewChatPage && ( -
- -
- )} {!isSpeedMode && } diff --git a/frontend/components/auth/avatarDropdown.tsx b/frontend/components/auth/avatarDropdown.tsx index 9e03a073e2..e151a6eb0e 100644 --- a/frontend/components/auth/avatarDropdown.tsx +++ b/frontend/components/auth/avatarDropdown.tsx @@ -1,33 +1,74 @@ "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Dropdown, Avatar, Spin, Button, Tag, ConfigProvider } from "antd"; import { - UserRound, + Activity, + BookMarked, + Check, + ChevronRight, + Copyright, + ExternalLink, + Globe, LogOut, LogIn, + UserRound, UserRoundPlus, - UserCircle, } from "lucide-react"; +import { GithubOutlined } from "@ant-design/icons"; import type { ItemType } from "antd/es/menu/interface"; import Link from "next/link"; import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { getRoleColor } from "@/lib/auth"; +import { useDeployment } from "@/components/providers/deploymentProvider"; import { USER_ROLES } from "@/const/auth"; +import { APP_VERSION, languageOptions } from "@/const/constants"; +import { useAccountMonitoring } from "@/hooks/useAccountMonitoring"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import { useLanguageSwitch } from "@/lib/language"; +import { useGlobalConfigStore } from "@/stores/global"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; interface AvatarDropdownProps { rail?: boolean; + expanded?: boolean; + onOpenChange?: (open: boolean) => void; } -export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { +const MENU_POPUP_CLASS = + "[&_.ant-dropdown-menu]:!flex [&_.ant-dropdown-menu]:!flex-col [&_.ant-dropdown-menu]:!gap-1 " + + "[&_.ant-dropdown-menu]:!p-1 [&_.ant-dropdown-menu]:!rounded-[12px] [&_.ant-dropdown-menu]:!bg-white " + + "[&_.ant-dropdown-menu]:!shadow-[0_8px_24px_rgba(0,0,0,0.16)] " + + "[&_.ant-dropdown-menu]:!max-h-[calc(100dvh-16px)] [&_.ant-dropdown-menu]:!overflow-y-auto " + + "[&_.ant-dropdown-menu-item:not(.account-menu-identity)]:!h-10 [&_.ant-dropdown-menu-item]:!rounded-lg [&_.ant-dropdown-menu-item]:!p-2 " + + "[&_.ant-dropdown-menu-submenu-title]:!h-10 [&_.ant-dropdown-menu-submenu-title]:!rounded-lg [&_.ant-dropdown-menu-submenu-title]:!p-2 " + + "[&_.ant-dropdown-menu-title-content]:!min-w-0 [&_.ant-dropdown-menu-title-content]:!text-base " + + "[&_.ant-dropdown-menu-title-content]:!leading-6 [&_.ant-dropdown-menu-title-content]:!font-normal " + + "[&_.ant-dropdown-menu-item-icon]:!me-2 [&_.ant-dropdown-menu-item-icon]:!text-[20px] " + + "[&_.ant-dropdown-menu-item-divider]:!my-0 [&_.ant-dropdown-menu-item-divider]:!bg-[#f0f0f0] " + + "[&_.ant-dropdown-menu-item:not(.account-menu-readonly):hover]:!bg-[rgba(25,25,25,0.08)] " + + "[&_.ant-dropdown-menu-submenu-title:hover]:!bg-[rgba(25,25,25,0.08)] " + + "[&_.ant-dropdown-menu]:!text-[#191919] [&_.ant-dropdown-menu]:!tracking-[0px]"; + +export function AvatarDropdown({ + rail = false, + expanded = false, + onOpenChange, +}: AvatarDropdownProps) { const { user } = useAuthorizationContext(); const { isLoading, logout, openLoginModal, openRegisterModal } = useAuthenticationContext(); + const { appVersion } = useDeployment(); + const { config } = useGlobalConfigStore(); + const monitoringUrl = useAccountMonitoring(); + const { currentLanguage, handleLanguageChange } = useLanguageSwitch(); const [dropdownOpen, setDropdownOpen] = useState(false); + const changeOpen = (open: boolean) => { + setDropdownOpen(open); + onOpenChange?.(open); + }; const { t } = useTranslation("common"); const { confirm } = useConfirmModal(); @@ -41,8 +82,6 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { return t("auth.admin"); case USER_ROLES.DEV: return t("auth.dev"); - case USER_ROLES.USER: - return t("auth.user"); case USER_ROLES.ASSET_OWNER: return t("auth.assetOwner"); default: @@ -50,7 +89,6 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { } }; - // Show loading while authentication is in progress if (isLoading) { return rail ? (
@@ -60,154 +98,248 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { ); } - if (!user) { - const items: ItemType[] = [ - { - key: "not-logged-in", - label: ( -
-
- {t("auth.notLoggedIn")} + + const identity = user?.username?.trim() || user?.email?.trim() || "-"; + const profileLocale = currentLanguage.split("-")[0] === "en" ? "en" : "zh"; + const accountItems: ItemType[] = user + ? [ + { + key: "user-info", + label: ( +
+
+ {identity} +
+ + {getRoleDisplayName(user.role)} +
-
- ), - className: "cursor-default hover:bg-transparent", - style: { - backgroundColor: "transparent", - cursor: "default", + ), + className: + "account-menu-identity account-menu-readonly !h-[68px] !cursor-default hover:!bg-transparent", }, - }, - { - type: "divider", - }, - { - key: "login", - icon: , - label: t("auth.login"), - onClick: () => { - setDropdownOpen(false); - openLoginModal(); + { type: "divider" }, + { + key: "profile", + icon: , + label: ( + + {t("profile.title")} + + ), }, - }, - { - key: "register", - icon: , - label: t("auth.register"), - onClick: () => { - setDropdownOpen(false); - openRegisterModal(); + ] + : [ + { + key: "not-logged-in", + label: t("auth.notLoggedIn"), + className: + "account-menu-readonly !cursor-default hover:!bg-transparent", }, - }, - ]; - - return ( - document.body}> - ( -
{menu}
- )} - getPopupContainer={() => document.body} - > - + ) : ( + + ) ) : ( - } - /> + )}
diff --git a/frontend/components/navigation/NavigationRailLabel.tsx b/frontend/components/navigation/NavigationRailLabel.tsx new file mode 100644 index 0000000000..9a5aa182bf --- /dev/null +++ b/frontend/components/navigation/NavigationRailLabel.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Typography } from "antd"; + +interface NavigationRailLabelProps { + expanded: boolean; + label: string; +} + +export function NavigationRailLabel({ + expanded, + label, +}: NavigationRailLabelProps) { + if (!expanded) return null; + return ( + + {label} + + ); +} diff --git a/frontend/components/navigation/NotificationBell.tsx b/frontend/components/navigation/NotificationBell.tsx index 86117d465b..847de0c622 100644 --- a/frontend/components/navigation/NotificationBell.tsx +++ b/frontend/components/navigation/NotificationBell.tsx @@ -13,12 +13,15 @@ import { } from "@/lib/notificationNavigation"; import { formatDateTime } from "@/lib/utils"; import type { NotificationItem } from "@/types/notification"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; interface NotificationBellProps { unreadCount: number; items: NotificationItem[]; isLoading: boolean; rail?: boolean; + expanded?: boolean; + onOpenChange?: (open: boolean) => void; onMarkRead?: (receiverId: number) => Promise; onMarkAllRead?: () => Promise; isMarkingAllRead?: boolean; @@ -29,6 +32,8 @@ export function NotificationBell({ items, isLoading, rail = false, + expanded = false, + onOpenChange, onMarkRead, onMarkAllRead, isMarkingAllRead = false, @@ -38,6 +43,10 @@ export function NotificationBell({ const params = useParams<{ locale: string }>(); const locale = params.locale || "en"; const [dropdownOpen, setDropdownOpen] = useState(false); + const changeOpen = (open: boolean) => { + setDropdownOpen(open); + onOpenChange?.(open); + }; const tooltipTitle = unreadCount > 0 @@ -54,7 +63,7 @@ export function NotificationBell({ return; } - setDropdownOpen(false); + changeOpen(false); if (onMarkRead) { await onMarkRead(item.receiver_id); } @@ -144,27 +153,33 @@ export function NotificationBell({ ) : ( document.body} popupRender={() => panel} > diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index 9131b8f977..33b2c27211 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -1,6 +1,12 @@ "use client"; -import { useEffect, useMemo, useState, type ComponentType } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; import { useTranslation } from "react-i18next"; import { useParams, usePathname, useRouter } from "next/navigation"; import Link from "next/link"; @@ -26,6 +32,8 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; import { NotificationBell } from "@/components/navigation/NotificationBell"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; +import { useGlobalConfigStore } from "@/stores/global"; import { AUTH_EVENTS } from "@/const/auth"; import { useMarkAllNotificationsRead, @@ -164,12 +172,9 @@ const ROUTE_CONFIG: RouteConfig[] = [ ]; const RAIL_ITEM_CLASS = - "flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-[4px] border-0 bg-transparent p-2 text-[#191919]"; -const MENU_ITEM_CLASS = `${RAIL_ITEM_CLASS} !h-auto min-h-12 !px-0 !py-1`; + "flex h-11 w-full shrink-0 items-center overflow-hidden rounded-lg border-0 bg-transparent p-0 pr-2 text-left text-[#191919] hover:bg-[rgba(25,25,25,0.08)]"; const RAIL_ICON_CLASS = "h-5 w-5 shrink-0"; -const RAIL_LABEL_CLASS = - "w-11 max-w-11 break-words text-center text-[12px] leading-[20px] ![letter-spacing:0px] text-[#191919]"; -const SELECTED_ITEM_CLASS = "!bg-[rgba(25,25,25,0.05)] !rounded-[12px]"; +const SELECTED_ITEM_CLASS = "!bg-[rgba(25,25,25,0.08)]"; function isRouteAccessible( route: RouteConfig, @@ -192,6 +197,34 @@ export function SideNavigation() { const pathname = usePathname(); const params = useParams<{ locale: string }>(); const locale = params.locale === "en" ? "en" : "zh"; + const { config } = useGlobalConfigStore(); + const [expanded, setExpanded] = useState(false); + const hovered = useRef(false); + const popups = useRef({ account: false, notification: false }); + const closeTimer = useRef | null>(null); + const expand = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + setExpanded(true); + }; + const collapseLater = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + if (popups.current.account || popups.current.notification) return; + closeTimer.current = setTimeout(() => setExpanded(false), 160); + }; + const handlePopupChange = ( + key: "account" | "notification", + open: boolean + ) => { + popups.current[key] = open; + if (open) expand(); + else if (!hovered.current) collapseLater(); + }; + useEffect( + () => () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + }, + [] + ); const [pendingNavigationPath, setPendingNavigationPath] = useState< string | null @@ -267,6 +300,8 @@ export function SideNavigation() { const notificationAction = isSpeedMode ? null : isAuthenticated ? ( handlePopupChange("notification", open)} unreadCount={unreadCount} items={items} isLoading={isNotificationsLoading} @@ -289,26 +324,57 @@ export function SideNavigation() { } }} > - + + + + ); return ( -
+
{ + hovered.current = true; + expand(); + }} + onMouseLeave={() => { + hovered.current = false; + collapseLater(); + }} + onFocusCapture={expand} + onBlurCapture={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + !hovered.current + ) + collapseLater(); + }} + > - logo + logo + + -