feat(layout): update sidebar layout and add tooltip for navigation badges

This commit is contained in:
Supra4E8C
2026-07-25 01:59:45 +08:00
parent e677a68c4d
commit aef7ff0991
6 changed files with 266 additions and 103 deletions
+92 -25
View File
@@ -7,13 +7,14 @@ import {
useLayoutEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import { pluginsApi } from '@/services/api';
import { authFilesApi, pluginsApi } from '@/services/api';
import {
IconSidebarAuthFiles,
IconSidebarConfig,
@@ -69,6 +70,7 @@ interface SidebarNavLinkItem {
metaKey?: string;
label?: string;
meta?: string;
badge?: number;
icon: ReactNode;
}
@@ -317,6 +319,12 @@ export function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [authFilesCount, setAuthFilesCount] = useState<number | null>(null);
const [railTooltip, setRailTooltip] = useState<{
label: string;
meta?: string;
top: number;
} | null>(null);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const [pluginResources, setPluginResources] = useState<PluginResourceEntry[]>([]);
@@ -449,9 +457,24 @@ export function MainLayout() {
}
}, [connectionStatus, supportsPlugin]);
const loadAuthFilesCount = useCallback(async () => {
if (connectionStatus !== 'connected') {
setAuthFilesCount(null);
return;
}
try {
const response = await authFilesApi.list();
setAuthFilesCount(Array.isArray(response?.files) ? response.files.length : null);
} catch {
setAuthFilesCount(null);
}
}, [connectionStatus]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadPluginResources();
void loadAuthFilesCount();
}, 0);
window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
@@ -460,7 +483,7 @@ export function MainLayout() {
window.clearTimeout(timer);
window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, loadPluginResources);
};
}, [apiBase, loadPluginResources]);
}, [apiBase, loadPluginResources, loadAuthFilesCount]);
const pluginResourceGroups = pluginResources.reduce<
Array<{ pluginID: string; pluginTitle: string; entries: PluginResourceEntry[] }>
@@ -550,6 +573,7 @@ export function MainLayout() {
path: '/auth-files',
labelKey: 'nav.auth_files',
metaKey: 'nav_meta.auth_files',
badge: authFilesCount ?? undefined,
icon: sidebarIcons.authFiles,
},
{
@@ -668,6 +692,7 @@ export function MainLayout() {
const results = await Promise.allSettled([
fetchConfig(true),
loadPluginResources(),
loadAuthFilesCount(),
triggerHeaderRefresh(),
]);
const rejected = results.find((result) => result.status === 'rejected');
@@ -696,6 +721,22 @@ export function MainLayout() {
});
}, []);
const showRailTooltip = useCallback(
(event: ReactMouseEvent<HTMLElement>, label: string, meta?: string) => {
const rect = event.currentTarget.getBoundingClientRect();
setRailTooltip({ label, meta, top: rect.top + rect.height / 2 });
},
[]
);
const hideRailTooltip = useCallback(() => setRailTooltip(null), []);
const renderNavBadge = (badge?: number) =>
typeof badge === 'number' && badge > 0 ? (
<span className="nav-badge" aria-hidden="true">
{badge}
</span>
) : null;
const renderNavLink = (item: SidebarNavLinkItem, className = 'nav-item') => {
const itemLabel = item.label ?? (item.labelKey ? t(item.labelKey) : '');
const itemMeta = item.meta ?? (item.metaKey ? t(item.metaKey) : '');
@@ -705,15 +746,26 @@ export function MainLayout() {
key={item.path}
to={item.path}
className={({ isActive }) => `${className} ${isActive ? 'active' : ''}`}
onClick={() => setSidebarOpen(false)}
title={showSidebarLabels ? undefined : itemLabel}
onClick={() => {
setSidebarOpen(false);
hideRailTooltip();
}}
aria-label={showSidebarLabels ? undefined : itemLabel}
onMouseEnter={
showSidebarLabels ? undefined : (event) => showRailTooltip(event, itemLabel, itemMeta)
}
onMouseLeave={showSidebarLabels ? undefined : hideRailTooltip}
>
<span className="nav-icon">{item.icon}</span>
{showSidebarLabels && (
<span className="nav-text">
<span className="nav-label">{itemLabel}</span>
{itemMeta ? <span className="nav-meta">{itemMeta}</span> : null}
</span>
{showSidebarLabels ? (
<>
<span className="nav-text">
<span className="nav-label">{itemLabel}</span>
</span>
{renderNavBadge(item.badge)}
</>
) : (
renderNavBadge(item.badge)
)}
</NavLink>
);
@@ -735,15 +787,18 @@ export function MainLayout() {
isOpen ? 'open' : ''
}`}
onClick={() => togglePluginResourceDrawer(item.id)}
title={showSidebarLabels ? undefined : item.label}
aria-label={showSidebarLabels ? undefined : item.label}
aria-expanded={isOpen}
onMouseEnter={
showSidebarLabels ? undefined : (event) => showRailTooltip(event, item.label, item.meta)
}
onMouseLeave={showSidebarLabels ? undefined : hideRailTooltip}
>
<span className="nav-icon">{item.icon}</span>
{showSidebarLabels && (
<>
<span className="nav-text">
<span className="nav-label">{item.label}</span>
{item.meta ? <span className="nav-meta">{item.meta}</span> : null}
</span>
<span className="nav-drawer-caret" aria-hidden="true">
<IconChevronDown size={14} />
@@ -776,17 +831,12 @@ export function MainLayout() {
<button
type="button"
className="sidebar-toggle-floating"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
aria-label={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
onClick={() => {
hideRailTooltip();
setSidebarCollapsed((prev) => !prev);
}}
title={sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
aria-label={sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
@@ -939,9 +989,16 @@ export function MainLayout() {
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="sidebar-brand" title={fullBrandName}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
{showSidebarLabels && <span className="sidebar-brand-title">{abbrBrandName}</span>}
<div className="sidebar-header">
<div className="sidebar-brand" title={fullBrandName}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
{showSidebarLabels && (
<span className="sidebar-brand-text">
<span className="sidebar-brand-title">{abbrBrandName}</span>
<span className="sidebar-brand-subtitle">{t('sidebar.subtitle')}</span>
</span>
)}
</div>
</div>
<div className="nav-section">
@@ -959,8 +1016,18 @@ export function MainLayout() {
</div>
))}
</div>
</aside>
{!showSidebarLabels && railTooltip && (
<div className="nav-tooltip" role="tooltip" style={{ top: railTooltip.top }}>
<span className="nav-tooltip-label">{railTooltip.label}</span>
{railTooltip.meta ? (
<span className="nav-tooltip-meta">{railTooltip.meta}</span>
) : null}
</div>
)}
<div
className={`content${isLogsPage ? ' content-logs' : ''}${
isPluginResourcePage ? ' content-plugin-resource' : ''
+4 -1
View File
@@ -1294,7 +1294,10 @@
},
"sidebar": {
"toggle_expand": "Expand sidebar",
"toggle_collapse": "Collapse sidebar"
"toggle_collapse": "Collapse sidebar",
"expand": "Expand",
"collapse": "Collapse",
"subtitle": "CLI Proxy API Console"
},
"footer": {
"api_version": "CLI Proxy API Version",
+4 -1
View File
@@ -1272,7 +1272,10 @@
},
"sidebar": {
"toggle_expand": "Развернуть боковую панель",
"toggle_collapse": "Свернуть боковую панель"
"toggle_collapse": "Свернуть боковую панель",
"expand": "Развернуть",
"collapse": "Свернуть",
"subtitle": "Консоль управления CLI Proxy API"
},
"footer": {
"api_version": "Версия CLI Proxy API",
+4 -1
View File
@@ -1294,7 +1294,10 @@
},
"sidebar": {
"toggle_expand": "展开侧边栏",
"toggle_collapse": "收起侧边栏"
"toggle_collapse": "收起侧边栏",
"expand": "展开",
"collapse": "收起",
"subtitle": "CLI Proxy API 管理控制台"
},
"footer": {
"api_version": "CLI Proxy API 版本",
+4 -1
View File
@@ -1320,7 +1320,10 @@
},
"sidebar": {
"toggle_expand": "展開側邊欄",
"toggle_collapse": "收起側邊欄"
"toggle_collapse": "收起側邊欄",
"expand": "展開",
"collapse": "收起",
"subtitle": "CLI Proxy API 管理控制台"
},
"footer": {
"api_version": "CLI Proxy API 版本",
+158 -74
View File
@@ -3,8 +3,8 @@
:root {
--header-height: 80px;
--shell-gutter: 24px;
--sidebar-panel-width: 256px;
--sidebar-collapsed-width: 68px;
--sidebar-panel-width: 216px;
--sidebar-collapsed-width: 60px;
--sidebar-active-width: var(--sidebar-panel-width);
--sidebar-gap: 0px;
--floating-control-size: 38px;
@@ -395,10 +395,10 @@
z-index: $z-dropdown - 1;
width: var(--sidebar-panel-width);
height: 100vh;
padding: 18px 14px;
padding: 14px 10px;
display: flex;
flex-direction: column;
gap: $spacing-md;
gap: $spacing-sm;
flex-shrink: 0;
overflow-y: auto;
background: var(--bg-primary);
@@ -422,19 +422,40 @@
&.collapsed {
width: var(--sidebar-collapsed-width);
padding: 18px 10px;
padding: 14px 8px;
.sidebar-brand {
.sidebar-header {
justify-content: center;
padding-right: 0;
padding-left: 0;
}
.sidebar-brand {
justify-content: center;
padding: 0;
}
.nav-item {
position: relative;
justify-content: center;
padding: 10px;
}
.nav-badge {
position: absolute;
top: 1px;
right: 1px;
margin-left: 0;
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: $radius-full;
font-size: 9px;
color: #fff;
background: var(--error-color);
box-shadow: 0 0 0 2px var(--bg-primary);
}
.nav-group-label {
display: none;
}
@@ -454,41 +475,67 @@
}
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 2px 2px 10px;
margin-bottom: 2px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
flex-shrink: 0;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 4px 10px 14px;
margin-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
min-width: 0;
flex: 1;
padding: 0 6px;
color: var(--text-primary);
flex-shrink: 0;
}
.sidebar-brand-logo {
width: 34px;
height: 34px;
width: 30px;
height: 30px;
object-fit: contain;
border-radius: 8px;
border-radius: 7px;
box-shadow: none;
flex-shrink: 0;
}
.sidebar-brand-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.15;
}
.sidebar-brand-title {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 18px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0;
white-space: nowrap;
}
.sidebar-brand-subtitle {
margin-top: 1px;
overflow: hidden;
font-size: 10.5px;
font-weight: 500;
color: color-mix(in srgb, var(--text-primary) 46%, transparent);
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 14px;
gap: 10px;
flex: 1;
}
@@ -504,8 +551,8 @@
}
.nav-group-label {
padding: 0 12px 4px;
font-size: 10.5px;
padding: 0 10px 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -548,22 +595,23 @@
.nav-sub-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 21px;
gap: 2px;
margin-left: 18px;
padding-left: 10px;
border-left: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
}
.nav-item {
padding: 9px 12px;
border-radius: 11px;
padding: 6px 10px;
border-radius: 9px;
border: 1px solid transparent;
color: var(--text-primary);
font-weight: 650;
font-size: 13px;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
gap: $spacing-sm;
gap: 10px;
cursor: pointer;
transition:
background $transition-fast,
@@ -572,35 +620,26 @@
transform $transition-fast;
.nav-icon {
width: 28px;
height: 28px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: currentColor;
opacity: 0.96;
border-radius: 8px;
background:
linear-gradient(180deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0)),
color-mix(in srgb, var(--bg-secondary) 84%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-primary) 82%, transparent);
transition:
background $transition-fast,
box-shadow $transition-fast,
color $transition-fast;
color: color-mix(in srgb, var(--text-primary) 58%, transparent);
transition: color $transition-fast;
svg {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
display: block;
}
img {
display: block;
width: 18px;
height: 18px;
border-radius: 5px;
width: 16px;
height: 16px;
border-radius: 4px;
object-fit: cover;
}
}
@@ -619,14 +658,21 @@
white-space: nowrap;
}
.nav-meta {
margin-top: 2px;
font-size: 11.5px;
font-weight: 500;
color: color-mix(in srgb, var(--text-primary) 50%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.nav-badge {
min-width: 18px;
height: 18px;
margin-left: auto;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: $radius-full;
background: color-mix(in srgb, var(--text-primary) 9%, transparent);
color: color-mix(in srgb, var(--text-primary) 58%, transparent);
font-size: 10.5px;
font-weight: 700;
line-height: 1;
}
&:hover {
@@ -635,14 +681,7 @@
transform: translateX(1px);
.nav-icon {
background:
linear-gradient(180deg, rgb(255 255 255 / 0.16), rgb(255 255 255 / 0)),
color-mix(in srgb, var(--bg-primary) 88%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-hover) 86%, transparent);
}
.nav-meta {
color: color-mix(in srgb, var(--text-primary) 62%, transparent);
color: color-mix(in srgb, var(--text-primary) 82%, transparent);
}
}
@@ -654,28 +693,19 @@
.nav-icon {
color: var(--primary-active);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.18), rgb(255 255 255 / 0)),
color-mix(in srgb, var(--primary-color) 14%, var(--bg-primary));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 24%, transparent);
}
.nav-meta {
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
}
}
&.nav-sub-item {
min-height: 34px;
padding: 7px 10px;
border-radius: 9px;
font-size: 13px;
min-height: 30px;
padding: 5px 10px;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
.nav-icon {
width: 18px;
height: 18px;
border-radius: 6px;
width: 16px;
height: 16px;
}
}
@@ -689,6 +719,48 @@
}
}
.nav-tooltip {
position: fixed;
left: calc(var(--sidebar-collapsed-width) + 12px);
z-index: $z-dropdown + 2;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 3px;
max-width: 260px;
padding: 9px 12px;
border-radius: 10px;
background: #24211c;
color: #f6f4f1;
box-shadow: 0 14px 36px rgb(0 0 0 / 0.32);
pointer-events: none;
animation: nav-tooltip-in $transition-fast ease-out;
.nav-tooltip-label {
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.nav-tooltip-meta {
font-size: 11.5px;
color: rgb(246 244 241 / 0.6);
white-space: nowrap;
}
}
@keyframes nav-tooltip-in {
from {
opacity: 0;
transform: translateY(-50%) translateX(-4px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.content {
flex: 1;
display: flex;
@@ -857,12 +929,24 @@
&.collapsed {
width: var(--sidebar-panel-width);
padding: 18px 14px;
padding: 14px 10px;
.sidebar-brand,
.nav-item {
justify-content: flex-start;
}
.nav-badge {
position: static;
min-width: 18px;
height: 18px;
margin-left: auto;
padding: 0 5px;
font-size: 10.5px;
color: color-mix(in srgb, var(--text-primary) 58%, transparent);
background: color-mix(in srgb, var(--text-primary) 9%, transparent);
box-shadow: none;
}
}
}