✨ 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:
@@ -20,11 +20,6 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||||
import { PieChart } from 'lucide-react';
|
import { PieChart } from 'lucide-react';
|
||||||
import {
|
|
||||||
IconHistogram,
|
|
||||||
IconPulse,
|
|
||||||
IconPieChart2Stroked,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import { VChart } from '@visactor/react-vchart';
|
import { VChart } from '@visactor/react-vchart';
|
||||||
|
|
||||||
const ChartsPanel = ({
|
const ChartsPanel = ({
|
||||||
@@ -51,46 +46,14 @@ const ChartsPanel = ({
|
|||||||
{t('模型数据分析')}
|
{t('模型数据分析')}
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
type='button'
|
type='slash'
|
||||||
activeKey={activeChartTab}
|
activeKey={activeChartTab}
|
||||||
onChange={setActiveChartTab}
|
onChange={setActiveChartTab}
|
||||||
>
|
>
|
||||||
<TabPane
|
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
||||||
tab={
|
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||||
<span>
|
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||||
<IconHistogram />
|
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||||
{t('消耗分布')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='1'
|
|
||||||
/>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconPulse />
|
|
||||||
{t('消耗趋势')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='2'
|
|
||||||
/>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconPieChart2Stroked />
|
|
||||||
{t('调用次数分布')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='3'
|
|
||||||
/>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconHistogram />
|
|
||||||
{t('调用次数排行')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='4'
|
|
||||||
/>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Typography, Tag } from '@douyinfe/semi-ui';
|
import { Typography, Tag } from '@douyinfe/semi-ui';
|
||||||
import SkeletonWrapper from './SkeletonWrapper';
|
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||||
|
|
||||||
const HeaderLogo = ({
|
const HeaderLogo = ({
|
||||||
isMobile,
|
isMobile,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import SkeletonWrapper from './SkeletonWrapper';
|
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||||
|
|
||||||
const Navigation = ({
|
const Navigation = ({
|
||||||
mainNavLinks,
|
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,
|
IconKey,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { stringToColor } from '../../../helpers';
|
import { stringToColor } from '../../../helpers';
|
||||||
import SkeletonWrapper from './SkeletonWrapper';
|
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||||
|
|
||||||
const UserArea = ({
|
const UserArea = ({
|
||||||
userState,
|
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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import HeaderBar from './HeaderBar';
|
import HeaderBar from './headerbar';
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
import SiderBar from './SiderBar';
|
import SiderBar from './SiderBar';
|
||||||
import App from '../../App';
|
import App from '../../App';
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { getLucideIcon } from '../../helpers/render';
|
|||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||||
import { useSidebar } from '../../hooks/common/useSidebar';
|
import { useSidebar } from '../../hooks/common/useSidebar';
|
||||||
|
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||||
import { isAdmin, isRoot, showError } from '../../helpers';
|
import { isAdmin, isRoot, showError } from '../../helpers';
|
||||||
|
import SkeletonWrapper from './components/SkeletonWrapper';
|
||||||
|
|
||||||
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
@@ -56,6 +58,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||||||
loading: sidebarLoading,
|
loading: sidebarLoading,
|
||||||
} = useSidebar();
|
} = useSidebar();
|
||||||
|
|
||||||
|
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 500);
|
||||||
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||||
const [chatItems, setChatItems] = useState([]);
|
const [chatItems, setChatItems] = useState([]);
|
||||||
const [openedKeys, setOpenedKeys] = useState([]);
|
const [openedKeys, setOpenedKeys] = useState([]);
|
||||||
@@ -377,120 +381,137 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||||||
className='sidebar-container'
|
className='sidebar-container'
|
||||||
style={{ width: 'var(--sidebar-current-width)' }}
|
style={{ width: 'var(--sidebar-current-width)' }}
|
||||||
>
|
>
|
||||||
<Nav
|
<SkeletonWrapper
|
||||||
className='sidebar-nav'
|
loading={showSkeleton}
|
||||||
defaultIsCollapsed={collapsed}
|
type='sidebar'
|
||||||
isCollapsed={collapsed}
|
className=''
|
||||||
onCollapseChange={toggleCollapsed}
|
collapsed={collapsed}
|
||||||
selectedKeys={selectedKeys}
|
showAdmin={isAdmin()}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 聊天区域 */}
|
<Nav
|
||||||
{hasSectionVisibleModules('chat') && (
|
className='sidebar-nav'
|
||||||
<div className='sidebar-section'>
|
defaultIsCollapsed={collapsed}
|
||||||
{!collapsed && (
|
isCollapsed={collapsed}
|
||||||
<div className='sidebar-group-label'>{t('聊天')}</div>
|
onCollapseChange={toggleCollapsed}
|
||||||
)}
|
selectedKeys={selectedKeys}
|
||||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
itemStyle='sidebar-nav-item'
|
||||||
</div>
|
hoverStyle='sidebar-nav-item:hover'
|
||||||
)}
|
selectedStyle='sidebar-nav-item-selected'
|
||||||
|
renderWrapper={({ itemElement, props }) => {
|
||||||
|
const to =
|
||||||
|
routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||||
|
|
||||||
{/* 控制台区域 */}
|
// 如果没有路由,直接返回元素
|
||||||
{hasSectionVisibleModules('console') && (
|
if (!to) return itemElement;
|
||||||
<>
|
|
||||||
<Divider className='sidebar-divider' />
|
|
||||||
<div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
|
||||||
)}
|
|
||||||
{workspaceItems.map((item) => renderNavItem(item))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 个人中心区域 */}
|
return (
|
||||||
{hasSectionVisibleModules('personal') && (
|
<Link
|
||||||
<>
|
style={{ textDecoration: 'none' }}
|
||||||
<Divider className='sidebar-divider' />
|
to={to}
|
||||||
<div>
|
onClick={onNavigate}
|
||||||
{!collapsed && (
|
>
|
||||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
{itemElement}
|
||||||
)}
|
</Link>
|
||||||
{financeItems.map((item) => renderNavItem(item))}
|
);
|
||||||
</div>
|
}}
|
||||||
</>
|
onSelect={(key) => {
|
||||||
)}
|
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||||
|
if (openedKeys.includes(key.itemKey)) {
|
||||||
|
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||||
|
}
|
||||||
|
|
||||||
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
|
setSelectedKeys([key.itemKey]);
|
||||||
{isAdmin() && hasSectionVisibleModules('admin') && (
|
}}
|
||||||
<>
|
openKeys={openedKeys}
|
||||||
<Divider className='sidebar-divider' />
|
onOpenChange={(data) => {
|
||||||
<div>
|
setOpenedKeys(data.openKeys);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 聊天区域 */}
|
||||||
|
{hasSectionVisibleModules('chat') && (
|
||||||
|
<div className='sidebar-section'>
|
||||||
{!collapsed && (
|
{!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>
|
</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'>
|
<div className='sidebar-collapse-button'>
|
||||||
<Button
|
<SkeletonWrapper
|
||||||
theme='outline'
|
loading={showSkeleton}
|
||||||
type='tertiary'
|
type='button'
|
||||||
size='small'
|
width={collapsed ? 36 : 156}
|
||||||
icon={
|
height={24}
|
||||||
<ChevronLeft
|
className='w-full'
|
||||||
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%' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{!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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -91,6 +91,7 @@ const UpstreamConflictModal = ({
|
|||||||
{
|
{
|
||||||
title: t('模型'),
|
title: t('模型'),
|
||||||
dataIndex: 'model_name',
|
dataIndex: 'model_name',
|
||||||
|
fixed: 'left',
|
||||||
render: (text) => <Text strong>{text}</Text>,
|
render: (text) => <Text strong>{text}</Text>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -235,7 +236,12 @@ const UpstreamConflictModal = ({
|
|||||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||||
</div>
|
</div>
|
||||||
<Table columns={columns} dataSource={dataSource} pagination={false} />
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ code {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
margin-top: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--semi-color-bg-0);
|
background-color: var(--semi-color-bg-0);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|||||||
Reference in New Issue
Block a user