✨ 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:
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal file
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user