feat: Add skeleton loading states for sidebar navigation

Add comprehensive skeleton screen implementation for sidebar to improve loading UX, matching the existing headerbar skeleton pattern.

## Features Added
- **Sidebar skeleton screens**: Complete 1:1 recreation of sidebar structure during loading
- **Responsive skeleton layouts**: Different layouts for expanded (164×30px) and collapsed (44×44px) states
- **Skeleton component enhancements**: Extended SkeletonWrapper with new skeleton types (sidebar, button, sidebarNavItem, sidebarGroupTitle)
- **Minimum loading time**: Integrated useMinimumLoadingTime hook with 500ms duration for smooth UX

## Layout Specifications
- **Expanded nav items**: 164×30px with 8px horizontal margins and 3px vertical margins
- **Collapsed nav items**: 44×44px with 4px bottom margin and 8px horizontal margins
- **Collapse button**: 156×24px (expanded) / 36×24px (collapsed) with rounded corners
- **Container padding**: 12px top padding, 8px horizontal margins
- **Group labels**: 4px 15px 8px padding matching real sidebar-group-label styles

## Code Improvements
- **Refactored skeleton rendering**: Eliminated code duplication using reusable components (NavRow, CollapsedRow)
- **Configuration-driven sections**: Sections defined as config objects with title widths and item widths
- **Fixed width calculations**: Removed random width generation, using precise fixed widths per menu item
- **Proper CSS class alignment**: Uses real sidebar CSS classes (sidebar-section, sidebar-group-label, sidebar-divider)

## UI/UX Enhancements
- **Bottom-aligned collapse button**: Fixed positioning using margin-top: auto to stay at viewport bottom
- **Accurate spacing**: Matches real sidebar margins, padding, and spacing exactly
- **Skeleton stability**: Fixed width values prevent layout shifts during loading
- **Clean file structure**: Removed redundant HeaderBar.js export file

## Technical Details
- Extended SkeletonWrapper component with sidebar-specific skeleton types
- Integrated skeleton loading state management in SiderBar component
- Added support for collapsed state awareness in skeleton rendering
- Implemented precise dimension matching for pixel-perfect loading states

Closes: Sidebar skeleton loading implementation
This commit is contained in:
t0ng7u
2025-09-02 03:38:01 +08:00
parent d15b31ab5c
commit d8e36a7057
11 changed files with 535 additions and 318 deletions

View File

@@ -1,20 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export { default } from './HeaderBar/index';

View File

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Link } from 'react-router-dom';
import { Typography, Tag } from '@douyinfe/semi-ui';
import SkeletonWrapper from './SkeletonWrapper';
import SkeletonWrapper from '../components/SkeletonWrapper';
const HeaderLogo = ({
isMobile,

View File

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Link } from 'react-router-dom';
import SkeletonWrapper from './SkeletonWrapper';
import SkeletonWrapper from '../components/SkeletonWrapper';
const Navigation = ({
mainNavLinks,

View File

@@ -1,148 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Skeleton } from '@douyinfe/semi-ui';
const SkeletonWrapper = ({
loading = false,
type = 'text',
count = 1,
width = 60,
height = 16,
isMobile = false,
className = '',
children,
...props
}) => {
if (!loading) {
return children;
}
// 导航链接骨架屏
const renderNavigationSkeleton = () => {
const skeletonLinkClasses = isMobile
? 'flex items-center gap-1 p-1 w-full rounded-md'
: 'flex items-center gap-1 p-2 rounded-md';
return Array(count)
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 40 : width, height }}
/>
}
/>
</div>
));
};
// 用户区域骨架屏 (头像 + 文本)
const renderUserAreaSkeleton = () => {
return (
<div
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
}
/>
<div className='ml-1.5 mr-1'>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 15 : width, height: 12 }}
/>
}
/>
</div>
</div>
);
};
// Logo图片骨架屏
const renderImageSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Image
active
className={`absolute inset-0 !rounded-full ${className}`}
style={{ width: '100%', height: '100%' }}
/>
}
/>
);
};
// 系统名称骨架屏
const renderTitleSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
/>
);
};
// 通用文本骨架屏
const renderTextSkeleton = () => {
return (
<div className={className}>
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height }} />}
/>
</div>
);
};
// 根据类型渲染不同的骨架屏
switch (type) {
case 'navigation':
return renderNavigationSkeleton();
case 'userArea':
return renderUserAreaSkeleton();
case 'image':
return renderImageSkeleton();
case 'title':
return renderTitleSkeleton();
case 'text':
default:
return renderTextSkeleton();
}
};
export default SkeletonWrapper;

View File

@@ -28,7 +28,7 @@ import {
IconKey,
} from '@douyinfe/semi-icons';
import { stringToColor } from '../../../helpers';
import SkeletonWrapper from './SkeletonWrapper';
import SkeletonWrapper from '../components/SkeletonWrapper';
const UserArea = ({
userState,

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import HeaderBar from './HeaderBar';
import HeaderBar from './headerbar';
import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar';
import App from '../../App';

View File

@@ -24,7 +24,9 @@ import { getLucideIcon } from '../../helpers/render';
import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useSidebar } from '../../hooks/common/useSidebar';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { isAdmin, isRoot, showError } from '../../helpers';
import SkeletonWrapper from './components/SkeletonWrapper';
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
@@ -56,6 +58,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
loading: sidebarLoading,
} = useSidebar();
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 500);
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
@@ -377,120 +381,137 @@ const SiderBar = ({ onNavigate = () => {} }) => {
className='sidebar-container'
style={{ width: 'var(--sidebar-current-width)' }}
>
<Nav
className='sidebar-nav'
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys}
itemStyle='sidebar-nav-item'
hoverStyle='sidebar-nav-item:hover'
selectedStyle='sidebar-nav-item-selected'
renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
// 如果没有路由,直接返回元素
if (!to) return itemElement;
return (
<Link
style={{ textDecoration: 'none' }}
to={to}
onClick={onNavigate}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
<SkeletonWrapper
loading={showSkeleton}
type='sidebar'
className=''
collapsed={collapsed}
showAdmin={isAdmin()}
>
{/* 聊天区域 */}
{hasSectionVisibleModules('chat') && (
<div className='sidebar-section'>
{!collapsed && (
<div className='sidebar-group-label'>{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
)}
<Nav
className='sidebar-nav'
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys}
itemStyle='sidebar-nav-item'
hoverStyle='sidebar-nav-item:hover'
selectedStyle='sidebar-nav-item-selected'
renderWrapper={({ itemElement, props }) => {
const to =
routerMapState[props.itemKey] || routerMap[props.itemKey];
{/* 控制台区域 */}
{hasSectionVisibleModules('console') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
</>
)}
// 如果没有路由,直接返回元素
if (!to) return itemElement;
{/* 个人中心区域 */}
{hasSectionVisibleModules('personal') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</>
)}
return (
<Link
style={{ textDecoration: 'none' }}
to={to}
onClick={onNavigate}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
{isAdmin() && hasSectionVisibleModules('admin') && (
<>
<Divider className='sidebar-divider' />
<div>
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* 聊天区域 */}
{hasSectionVisibleModules('chat') && (
<div className='sidebar-section'>
{!collapsed && (
<div className='sidebar-group-label'>{t('管理员')}</div>
<div className='sidebar-group-label'>{t('聊天')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
</>
)}
</Nav>
)}
{/* 控制台区域 */}
{hasSectionVisibleModules('console') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 个人中心区域 */}
{hasSectionVisibleModules('personal') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
{isAdmin() && hasSectionVisibleModules('admin') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
</>
)}
</Nav>
</SkeletonWrapper>
{/* 底部折叠按钮 */}
<div className='sidebar-collapse-button'>
<Button
theme='outline'
type='tertiary'
size='small'
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color='var(--semi-color-text-2)'
style={{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
onClick={toggleCollapsed}
icononly={collapsed}
style={
collapsed
? { padding: '4px', width: '100%' }
: { padding: '4px 12px', width: '100%' }
}
<SkeletonWrapper
loading={showSkeleton}
type='button'
width={collapsed ? 36 : 156}
height={24}
className='w-full'
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
<Button
theme='outline'
type='tertiary'
size='small'
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color='var(--semi-color-text-2)'
style={{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
onClick={toggleCollapsed}
icononly={collapsed}
style={
collapsed
? { width: 36, height: 24, padding: 0 }
: { padding: '4px 12px', width: '100%' }
}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
</SkeletonWrapper>
</div>
</div>
);

View File

@@ -0,0 +1,394 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Skeleton } from '@douyinfe/semi-ui';
const SkeletonWrapper = ({
loading = false,
type = 'text',
count = 1,
width = 60,
height = 16,
isMobile = false,
className = '',
collapsed = false,
showAdmin = true,
children,
...props
}) => {
if (!loading) {
return children;
}
// 导航链接骨架屏
const renderNavigationSkeleton = () => {
const skeletonLinkClasses = isMobile
? 'flex items-center gap-1 p-1 w-full rounded-md'
: 'flex items-center gap-1 p-2 rounded-md';
return Array(count)
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 40 : width, height }}
/>
}
/>
</div>
));
};
// 用户区域骨架屏 (头像 + 文本)
const renderUserAreaSkeleton = () => {
return (
<div
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
}
/>
<div className='ml-1.5 mr-1'>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 15 : width, height: 12 }}
/>
}
/>
</div>
</div>
);
};
// Logo图片骨架屏
const renderImageSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Image
active
className={`absolute inset-0 !rounded-full ${className}`}
style={{ width: '100%', height: '100%' }}
/>
}
/>
);
};
// 系统名称骨架屏
const renderTitleSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
/>
);
};
// 通用文本骨架屏
const renderTextSkeleton = () => {
return (
<div className={className}>
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title active style={{ width, height }} />}
/>
</div>
);
};
// 按钮骨架屏(支持圆角)
const renderButtonSkeleton = () => {
return (
<div className={className}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width, height, borderRadius: 9999 }}
/>
}
/>
</div>
);
};
// 侧边栏导航项骨架屏 (图标 + 文本)
const renderSidebarNavItemSkeleton = () => {
return Array(count)
.fill(null)
.map((_, index) => (
<div
key={index}
className={`flex items-center p-2 mb-1 rounded-md ${className}`}
>
{/* 图标骨架屏 */}
<div className='sidebar-icon-container flex-shrink-0 mr-2'>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar active size='extra-small' shape='square' />
}
/>
</div>
{/* 文本骨架屏 */}
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: width || 80, height: height || 14 }}
/>
}
/>
</div>
));
};
// 侧边栏组标题骨架屏
const renderSidebarGroupTitleSkeleton = () => {
return (
<div className={`mb-2 ${className}`}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: width || 60, height: height || 12 }}
/>
}
/>
</div>
);
};
// 完整侧边栏骨架屏 - 1:1 还原,去重实现
const renderSidebarSkeleton = () => {
const NAV_WIDTH = 164;
const NAV_HEIGHT = 30;
const COLLAPSED_WIDTH = 44;
const COLLAPSED_HEIGHT = 44;
const ICON_SIZE = 16;
const TITLE_HEIGHT = 12;
const TEXT_HEIGHT = 16;
const renderIcon = () => (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar
active
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
}
/>
);
const renderLabel = (labelWidth) => (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: labelWidth, height: TEXT_HEIGHT }}
/>
}
/>
);
const NavRow = ({ labelWidth }) => (
<div
className='flex items-center p-2 mb-1 rounded-md'
style={{
width: `${NAV_WIDTH}px`,
height: `${NAV_HEIGHT}px`,
margin: '3px 8px',
}}
>
<div className='sidebar-icon-container flex-shrink-0 mr-2'>
{renderIcon()}
</div>
{renderLabel(labelWidth)}
</div>
);
const CollapsedRow = ({ keyPrefix, index }) => (
<div
key={`${keyPrefix}-${index}`}
className='flex items-center justify-center'
style={{
width: `${COLLAPSED_WIDTH}px`,
height: `${COLLAPSED_HEIGHT}px`,
margin: '0 8px 4px 8px',
}}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar
active
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
}
/>
</div>
);
if (collapsed) {
return (
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
{Array(2)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-chat' index={i} />
))}
{Array(5)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-console' index={i} />
))}
{Array(2)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-personal' index={i} />
))}
{Array(5)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-admin' index={i} />
))}
</div>
);
}
const sections = [
{ key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' },
{ key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] },
{ key: 'personal', titleWidth: 64, itemWidths: [64, 64] },
...(showAdmin
? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }]
: []),
];
return (
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
{sections.map((sec, idx) => (
<React.Fragment key={sec.key}>
{sec.wrapper === 'section' ? (
<div className='sidebar-section'>
<div
className='sidebar-group-label'
style={{ padding: '4px 15px 8px' }}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}
/>
</div>
{sec.itemWidths.map((w, i) => (
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
))}
</div>
) : (
<div>
<div
className='sidebar-group-label'
style={{ padding: '4px 15px 8px' }}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}
/>
</div>
{sec.itemWidths.map((w, i) => (
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
))}
</div>
)}
</React.Fragment>
))}
</div>
);
};
// 根据类型渲染不同的骨架屏
switch (type) {
case 'navigation':
return renderNavigationSkeleton();
case 'userArea':
return renderUserAreaSkeleton();
case 'image':
return renderImageSkeleton();
case 'title':
return renderTitleSkeleton();
case 'sidebarNavItem':
return renderSidebarNavItemSkeleton();
case 'sidebarGroupTitle':
return renderSidebarGroupTitleSkeleton();
case 'sidebar':
return renderSidebarSkeleton();
case 'button':
return renderButtonSkeleton();
case 'text':
default:
return renderTextSkeleton();
}
};
export default SkeletonWrapper;