Просмотр исходного кода

✨ 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
t0ng7u 6 месяцев назад
Родитель
Сommit
5ac9ebdebb

+ 5 - 42
web/src/components/dashboard/ChartsPanel.jsx

@@ -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
-              tab={
-                <span>
-                  <IconHistogram />
-                  {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'
-            />
+            <TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
+            <TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
+            <TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
+            <TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
           </Tabs>
           </Tabs>
         </div>
         </div>
       }
       }

+ 0 - 20
web/src/components/layout/HeaderBar.js

@@ -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';

+ 1 - 1
web/src/components/layout/HeaderBar/HeaderLogo.jsx

@@ -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,

+ 1 - 1
web/src/components/layout/HeaderBar/Navigation.jsx

@@ -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,

+ 0 - 148
web/src/components/layout/HeaderBar/SkeletonWrapper.jsx

@@ -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;

+ 1 - 1
web/src/components/layout/HeaderBar/UserArea.jsx

@@ -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,

+ 1 - 1
web/src/components/layout/PageLayout.jsx

@@ -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';

+ 126 - 105
web/src/components/layout/SiderBar.jsx

@@ -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
-        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];
+
+            // 如果没有路由,直接返回元素
+            if (!to) return itemElement;
 
 
-        {/* 控制台区域 */}
-        {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>
+            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);
+          }}
+        >
+          {/* 聊天区域 */}
+          {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
-          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>
     </div>
     </div>
   );
   );

+ 394 - 0
web/src/components/layout/components/SkeletonWrapper.jsx

@@ -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;

+ 7 - 1
web/src/components/table/models/modals/UpstreamConflictModal.jsx

@@ -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>

+ 1 - 0
web/src/index.css

@@ -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;