Browse Source

📱 refactor(web): remove legacy isMobile util and migrate to useIsMobile hook

BREAKING CHANGE:
helpers/utils.js no longer exports `isMobile()`.
Any external code that relied on this function must switch to the `useIsMobile` React hook.

Summary
-------
1. Deleted the obsolete `isMobile()` function from helpers/utils.js.
2. Introduced `MOBILE_BREAKPOINT` constant and `matchMedia`-based detection for non-React contexts.
3. Reworked toast positioning logic in utils.js to rely on `matchMedia`.
4. Updated render.js:
   • Removed isMobile import.
   • Added MOBILE_BREAKPOINT detection in `truncateText`.
5. Migrated every page/component to the `useIsMobile` hook:
   • Layout: HeaderBar, PageLayout, SiderBar
   • Pages: Home, Detail, Playground, User (Add/Edit), Token, Channel, Redemption, Ratio Sync
   • Components: ChannelsTable, ChannelSelectorModal, ConflictConfirmModal
6. Purged all remaining `isMobile()` calls and legacy imports.
7. Added missing `const isMobile = useIsMobile()` declarations where required.

Benefits
--------
• Unifies mobile detection with a React-friendly hook.
• Eliminates duplicated logic and improves maintainability.
• Keeps non-React helpers lightweight by using `matchMedia` directly.
t0ng7u 6 months ago
parent
commit
a44fc51007

+ 12 - 10
web/src/components/layout/HeaderBar.js

@@ -31,13 +31,15 @@ import {
   Badge,
 } from '@douyinfe/semi-ui';
 import { StatusContext } from '../../context/Status/index.js';
-import { useStyle, styleActions } from '../../context/Style/index.js';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
 
-const HeaderBar = () => {
+const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const { t, i18n } = useTranslation();
   const [userState, userDispatch] = useContext(UserContext);
   const [statusState, statusDispatch] = useContext(StatusContext);
-  const { state: styleState, dispatch: styleDispatch } = useStyle();
+  const isMobile = useIsMobile();
+  const [collapsed, toggleCollapsed] = useSidebarCollapsed();
   const [isLoading, setIsLoading] = useState(true);
   let navigate = useNavigate();
   const [currentLang, setCurrentLang] = useState(i18n.language);
@@ -207,7 +209,7 @@ const HeaderBar = () => {
 
   const handleNavLinkClick = (itemKey) => {
     if (itemKey === 'home') {
-      styleDispatch(styleActions.setSider(false));
+      // styleDispatch(styleActions.setSider(false)); // This line is removed
     }
     setMobileMenuOpen(false);
   };
@@ -293,7 +295,7 @@ const HeaderBar = () => {
               placeholder={
                 <Skeleton.Title
                   active
-                  style={{ width: styleState.isMobile ? 15 : 50, height: 12 }}
+                  style={{ width: isMobile ? 15 : 50, height: 12 }}
                 />
               }
             />
@@ -388,7 +390,7 @@ const HeaderBar = () => {
       const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
 
       if (showRegisterButton) {
-        if (styleState.isMobile) {
+        if (isMobile) {
           loginButtonClasses += " !rounded-full";
         } else {
           loginButtonClasses += " !rounded-l-full !rounded-r-none";
@@ -436,7 +438,7 @@ const HeaderBar = () => {
       <NoticeModal
         visible={noticeVisible}
         onClose={handleNoticeClose}
-        isMobile={styleState.isMobile}
+        isMobile={isMobile}
         defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
         unreadKeys={getUnreadKeys()}
       />
@@ -447,18 +449,18 @@ const HeaderBar = () => {
               <Button
                 icon={
                   isConsoleRoute
-                    ? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
+                    ? ((isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
                     : (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
                 }
                 aria-label={
                   isConsoleRoute
-                    ? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
+                    ? ((isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏'))
                     : (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
                 }
                 onClick={() => {
                   if (isConsoleRoute) {
                     // 控制侧边栏的显示/隐藏,无论是否移动设备
-                    styleDispatch(styleActions.toggleSider());
+                    isMobile ? onMobileMenuToggle() : toggleCollapsed();
                   } else {
                     // 控制HeaderBar自己的移动菜单
                     setMobileMenuOpen(!mobileMenuOpen);

+ 25 - 18
web/src/components/layout/PageLayout.js

@@ -4,8 +4,9 @@ import SiderBar from './SiderBar.js';
 import App from '../../App.js';
 import FooterBar from './Footer.js';
 import { ToastContainer } from 'react-toastify';
-import React, { useContext, useEffect } from 'react';
-import { useStyle } from '../../context/Style/index.js';
+import React, { useContext, useEffect, useState } from 'react';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
 import { useTranslation } from 'react-i18next';
 import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
 import { UserContext } from '../../context/User/index.js';
@@ -16,7 +17,9 @@ const { Sider, Content, Header } = Layout;
 const PageLayout = () => {
   const [userState, userDispatch] = useContext(UserContext);
   const [statusState, statusDispatch] = useContext(StatusContext);
-  const { state: styleState } = useStyle();
+  const isMobile = useIsMobile();
+  const [collapsed, , setCollapsed] = useSidebarCollapsed();
+  const [drawerOpen, setDrawerOpen] = useState(false);
   const { i18n } = useTranslation();
   const location = useLocation();
 
@@ -26,6 +29,16 @@ const PageLayout = () => {
     !location.pathname.startsWith('/console/chat') &&
     location.pathname !== '/console/playground';
 
+  const isConsoleRoute = location.pathname.startsWith('/console');
+  const showSider = isConsoleRoute && (!isMobile || drawerOpen);
+
+  // Ensure sidebar not collapsed when opening drawer on mobile
+  useEffect(() => {
+    if (isMobile && drawerOpen && collapsed) {
+      setCollapsed(false);
+    }
+  }, [isMobile, drawerOpen, collapsed, setCollapsed]);
+
   const loadUser = () => {
     let user = localStorage.getItem('user');
     if (user) {
@@ -76,7 +89,7 @@ const PageLayout = () => {
         height: '100vh',
         display: 'flex',
         flexDirection: 'column',
-        overflow: styleState.isMobile ? 'visible' : 'hidden',
+        overflow: isMobile ? 'visible' : 'hidden',
       }}
     >
       <Header
@@ -90,25 +103,26 @@ const PageLayout = () => {
           zIndex: 100,
         }}
       >
-        <HeaderBar />
+        <HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
       </Header>
       <Layout
         style={{
-          overflow: styleState.isMobile ? 'visible' : 'auto',
+          overflow: isMobile ? 'visible' : 'auto',
           display: 'flex',
           flexDirection: 'column',
         }}
       >
-        {styleState.showSider && (
+        {showSider && (
           <Sider
             style={{
-              position: 'fixed',
+              position: isMobile ? 'fixed' : 'fixed',
               left: 0,
               top: '64px',
               zIndex: 99,
               border: 'none',
               paddingRight: '0',
               height: 'calc(100vh - 64px)',
+              width: 'var(--sidebar-current-width)',
             }}
           >
             <SiderBar />
@@ -116,14 +130,7 @@ const PageLayout = () => {
         )}
         <Layout
           style={{
-            marginLeft: styleState.isMobile
-              ? '0'
-              : styleState.showSider
-                ? styleState.siderCollapsed
-                  ? '60px'
-                  : '180px'
-                : '0',
-            transition: 'margin-left 0.3s ease',
+            marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
             flex: '1 1 auto',
             display: 'flex',
             flexDirection: 'column',
@@ -132,9 +139,9 @@ const PageLayout = () => {
           <Content
             style={{
               flex: '1 0 auto',
-              overflowY: styleState.isMobile ? 'visible' : 'hidden',
+              overflowY: isMobile ? 'visible' : 'hidden',
               WebkitOverflowScrolling: 'touch',
-              padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
+              padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
               position: 'relative',
             }}
           >

+ 22 - 38
web/src/components/layout/SiderBar.js

@@ -3,7 +3,8 @@ import { Link, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
 import { ChevronLeft } from 'lucide-react';
-import { useStyle, styleActions } from '../../context/Style/index.js';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
 import {
   isAdmin,
   isRoot,
@@ -36,10 +37,10 @@ const routerMap = {
 
 const SiderBar = () => {
   const { t } = useTranslation();
-  const { state: styleState, dispatch: styleDispatch } = useStyle();
+  const isMobile = useIsMobile();
+  const [collapsed, toggleCollapsed] = useSidebarCollapsed();
 
   const [selectedKeys, setSelectedKeys] = useState(['home']);
-  const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
   const [chatItems, setChatItems] = useState([]);
   const [openedKeys, setOpenedKeys] = useState([]);
   const location = useLocation();
@@ -217,10 +218,14 @@ const SiderBar = () => {
     }
   }, [location.pathname, routerMapState]);
 
-  // 同步折叠状态
+  // 监控折叠状态变化以更新 body class
   useEffect(() => {
-    setIsCollapsed(styleState.siderCollapsed);
-  }, [styleState.siderCollapsed]);
+    if (collapsed) {
+      document.body.classList.add('sidebar-collapsed');
+    } else {
+      document.body.classList.remove('sidebar-collapsed');
+    }
+  }, [collapsed]);
 
   // 获取菜单项对应的颜色
   const getItemColor = (itemKey) => {
@@ -323,32 +328,13 @@ const SiderBar = () => {
   return (
     <div
       className="sidebar-container"
-      style={{ width: isCollapsed ? '60px' : '180px' }}
+      style={{ width: 'var(--sidebar-current-width)' }}
     >
       <Nav
         className="sidebar-nav"
-        defaultIsCollapsed={styleState.siderCollapsed}
-        isCollapsed={isCollapsed}
-        onCollapseChange={(collapsed) => {
-          setIsCollapsed(collapsed);
-          styleDispatch(styleActions.setSiderCollapsed(collapsed));
-
-          // 确保在收起侧边栏时有选中的项目
-          if (selectedKeys.length === 0) {
-            const currentPath = location.pathname;
-            const matchingKey = Object.keys(routerMapState).find(
-              (key) => routerMapState[key] === currentPath,
-            );
-
-            if (matchingKey) {
-              setSelectedKeys([matchingKey]);
-            } else if (currentPath.startsWith('/console/chat/')) {
-              setSelectedKeys(['chat']);
-            } else {
-              setSelectedKeys(['detail']); // 默认选中首页
-            }
-          }
-        }}
+        defaultIsCollapsed={collapsed}
+        isCollapsed={collapsed}
+        onCollapseChange={toggleCollapsed}
         selectedKeys={selectedKeys}
         itemStyle="sidebar-nav-item"
         hoverStyle="sidebar-nav-item:hover"
@@ -383,7 +369,7 @@ const SiderBar = () => {
       >
         {/* 聊天区域 */}
         <div className="sidebar-section">
-          {!isCollapsed && (
+          {!collapsed && (
             <div className="sidebar-group-label">{t('聊天')}</div>
           )}
           {chatMenuItems.map((item) => renderSubItem(item))}
@@ -392,7 +378,7 @@ const SiderBar = () => {
         {/* 控制台区域 */}
         <Divider className="sidebar-divider" />
         <div>
-          {!isCollapsed && (
+          {!collapsed && (
             <div className="sidebar-group-label">{t('控制台')}</div>
           )}
           {workspaceItems.map((item) => renderNavItem(item))}
@@ -403,7 +389,7 @@ const SiderBar = () => {
           <>
             <Divider className="sidebar-divider" />
             <div>
-              {!isCollapsed && (
+              {!collapsed && (
                 <div className="sidebar-group-label">{t('管理员')}</div>
               )}
               {adminItems.map((item) => renderNavItem(item))}
@@ -414,7 +400,7 @@ const SiderBar = () => {
         {/* 个人中心区域 */}
         <Divider className="sidebar-divider" />
         <div>
-          {!isCollapsed && (
+          {!collapsed && (
             <div className="sidebar-group-label">{t('个人中心')}</div>
           )}
           {financeItems.map((item) => renderNavItem(item))}
@@ -425,16 +411,14 @@ const SiderBar = () => {
       <div
         className="sidebar-collapse-button"
         onClick={() => {
-          const newCollapsed = !isCollapsed;
-          setIsCollapsed(newCollapsed);
-          styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
+          toggleCollapsed();
         }}
       >
-        <Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
+        <Tooltip content={collapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
           <div className="sidebar-collapse-button-inner">
             <span
               className="sidebar-collapse-icon-container"
-              style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
+              style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
             >
               <ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
             </span>

+ 3 - 2
web/src/components/settings/ChannelSelectorModal.js

@@ -1,5 +1,5 @@
 import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
-import { isMobile } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import {
   Modal,
   Table,
@@ -26,6 +26,7 @@ const ChannelSelectorModal = forwardRef(({
   const [searchText, setSearchText] = useState('');
   const [currentPage, setCurrentPage] = useState(1);
   const [pageSize, setPageSize] = useState(10);
+  const isMobile = useIsMobile();
 
   const [filteredData, setFilteredData] = useState([]);
 
@@ -186,7 +187,7 @@ const ChannelSelectorModal = forwardRef(({
       onCancel={onCancel}
       onOk={onOk}
       title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
-      size={isMobile() ? 'full-width' : 'large'}
+      size={isMobile ? 'full-width' : 'large'}
       keepDOM
       lazyRender={false}
     >

+ 4 - 2
web/src/components/table/ChannelsTable.js

@@ -44,7 +44,8 @@ import {
   IconMore,
   IconDescend2
 } from '@douyinfe/semi-icons';
-import { loadChannelModels, isMobile, copy } from '../../helpers';
+import { loadChannelModels, copy } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import EditTagModal from '../../pages/Channel/EditTagModal.js';
 import { useTranslation } from 'react-i18next';
 import { useTableCompactMode } from '../../hooks/useTableCompactMode';
@@ -52,6 +53,7 @@ import { FaRandom } from 'react-icons/fa';
 
 const ChannelsTable = () => {
   const { t } = useTranslation();
+  const isMobile = useIsMobile();
 
   let type2label = undefined;
 
@@ -2031,7 +2033,7 @@ const ChannelsTable = () => {
         }
         maskClosable={!isBatchTesting}
         className="!rounded-lg"
-        size={isMobile() ? 'full-width' : 'large'}
+        size={isMobile ? 'full-width' : 'large'}
       >
         <div className="model-test-scroll">
           {currentTestChannel && (

+ 0 - 227
web/src/context/Style/index.js

@@ -1,227 +0,0 @@
-// contexts/Style/index.js
-
-import React, { useReducer, useEffect, useMemo, createContext } from 'react';
-import { useLocation } from 'react-router-dom';
-import { isMobile as getIsMobile } from '../../helpers';
-
-// Action Types
-const ACTION_TYPES = {
-  TOGGLE_SIDER: 'TOGGLE_SIDER',
-  SET_SIDER: 'SET_SIDER',
-  SET_MOBILE: 'SET_MOBILE',
-  SET_SIDER_COLLAPSED: 'SET_SIDER_COLLAPSED',
-  BATCH_UPDATE: 'BATCH_UPDATE',
-};
-
-// Constants
-const STORAGE_KEYS = {
-  SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
-};
-
-const ROUTE_PATTERNS = {
-  CONSOLE: '/console',
-};
-
-/**
- * 判断路径是否为控制台路由
- * @param {string} pathname - 路由路径
- * @returns {boolean} 是否为控制台路由
- */
-const isConsoleRoute = (pathname) => {
-  return pathname === ROUTE_PATTERNS.CONSOLE ||
-    pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
-};
-
-/**
- * 获取初始状态
- * @param {string} pathname - 当前路由路径
- * @returns {Object} 初始状态对象
- */
-const getInitialState = (pathname) => {
-  const isMobile = getIsMobile();
-  const isConsole = isConsoleRoute(pathname);
-  const isCollapsed = localStorage.getItem(STORAGE_KEYS.SIDEBAR_COLLAPSED) === 'true';
-
-  return {
-    isMobile,
-    showSider: isConsole && !isMobile,
-    siderCollapsed: isCollapsed,
-    isManualSiderControl: false,
-  };
-};
-
-/**
- * Style reducer
- * @param {Object} state - 当前状态
- * @param {Object} action - action 对象
- * @returns {Object} 新状态
- */
-const styleReducer = (state, action) => {
-  switch (action.type) {
-    case ACTION_TYPES.TOGGLE_SIDER:
-      return {
-        ...state,
-        showSider: !state.showSider,
-        isManualSiderControl: true,
-      };
-
-    case ACTION_TYPES.SET_SIDER:
-      return {
-        ...state,
-        showSider: action.payload,
-        isManualSiderControl: action.isManualControl ?? false,
-      };
-
-    case ACTION_TYPES.SET_MOBILE:
-      return {
-        ...state,
-        isMobile: action.payload,
-      };
-
-    case ACTION_TYPES.SET_SIDER_COLLAPSED:
-      // 自动保存到 localStorage
-      localStorage.setItem(STORAGE_KEYS.SIDEBAR_COLLAPSED, action.payload.toString());
-      return {
-        ...state,
-        siderCollapsed: action.payload,
-      };
-
-    case ACTION_TYPES.BATCH_UPDATE:
-      return {
-        ...state,
-        ...action.payload,
-      };
-
-    default:
-      return state;
-  }
-};
-
-// Context (内部使用,不导出)
-const StyleContext = createContext(null);
-
-/**
- * 自定义 Hook - 处理窗口大小变化
- * @param {Function} dispatch - dispatch 函数
- * @param {Object} state - 当前状态
- * @param {string} pathname - 当前路径
- */
-const useWindowResize = (dispatch, state, pathname) => {
-  useEffect(() => {
-    const handleResize = () => {
-      const isMobile = getIsMobile();
-      dispatch({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile });
-
-      // 只有在非手动控制的情况下,才根据屏幕大小自动调整侧边栏
-      if (!state.isManualSiderControl && isConsoleRoute(pathname)) {
-        dispatch({
-          type: ACTION_TYPES.SET_SIDER,
-          payload: !isMobile,
-          isManualControl: false
-        });
-      }
-    };
-
-    let timeoutId;
-    const debouncedResize = () => {
-      clearTimeout(timeoutId);
-      timeoutId = setTimeout(handleResize, 150);
-    };
-
-    window.addEventListener('resize', debouncedResize);
-    return () => {
-      window.removeEventListener('resize', debouncedResize);
-      clearTimeout(timeoutId);
-    };
-  }, [dispatch, state.isManualSiderControl, pathname]);
-};
-
-/**
- * 自定义 Hook - 处理路由变化
- * @param {Function} dispatch - dispatch 函数
- * @param {string} pathname - 当前路径
- */
-const useRouteChange = (dispatch, pathname) => {
-  useEffect(() => {
-    const isMobile = getIsMobile();
-    const isConsole = isConsoleRoute(pathname);
-
-    dispatch({
-      type: ACTION_TYPES.BATCH_UPDATE,
-      payload: {
-        showSider: isConsole && !isMobile,
-        isManualSiderControl: false,
-      },
-    });
-  }, [pathname, dispatch]);
-};
-
-/**
- * 自定义 Hook - 处理移动设备侧边栏自动收起
- * @param {Object} state - 当前状态
- * @param {Function} dispatch - dispatch 函数
- */
-const useMobileSiderAutoHide = (state, dispatch) => {
-  useEffect(() => {
-    // 移动设备上,如果不是手动控制且侧边栏是打开的,则自动关闭
-    if (state.isMobile && state.showSider && !state.isManualSiderControl) {
-      dispatch({ type: ACTION_TYPES.SET_SIDER, payload: false });
-    }
-  }, [state.isMobile, state.showSider, state.isManualSiderControl, dispatch]);
-};
-
-/**
- * Style Provider 组件
- */
-export const StyleProvider = ({ children }) => {
-  const location = useLocation();
-  const pathname = location.pathname;
-
-  const [state, dispatch] = useReducer(
-    styleReducer,
-    pathname,
-    getInitialState
-  );
-
-  useWindowResize(dispatch, state, pathname);
-  useRouteChange(dispatch, pathname);
-  useMobileSiderAutoHide(state, dispatch);
-
-  const contextValue = useMemo(
-    () => ({ state, dispatch }),
-    [state]
-  );
-
-  return (
-    <StyleContext.Provider value={contextValue}>
-      {children}
-    </StyleContext.Provider>
-  );
-};
-
-/**
- * 自定义 Hook - 使用 StyleContext
- * @returns {{state: Object, dispatch: Function}} context value
- */
-export const useStyle = () => {
-  const context = React.useContext(StyleContext);
-  if (!context) {
-    throw new Error('useStyle must be used within StyleProvider');
-  }
-  return context;
-};
-
-// 导出 action creators 以便外部使用
-export const styleActions = {
-  toggleSider: () => ({ type: ACTION_TYPES.TOGGLE_SIDER }),
-  setSider: (show, isManualControl = false) => ({
-    type: ACTION_TYPES.SET_SIDER,
-    payload: show,
-    isManualControl
-  }),
-  setMobile: (isMobile) => ({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile }),
-  setSiderCollapsed: (collapsed) => ({
-    type: ACTION_TYPES.SET_SIDER_COLLAPSED,
-    payload: collapsed
-  }),
-};

+ 4 - 2
web/src/helpers/render.js

@@ -1,6 +1,7 @@
 import i18next from 'i18next';
 import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
-import { copy, isMobile, showSuccess } from './utils';
+import { copy, showSuccess } from './utils';
+import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
 import { visit } from 'unist-util-visit';
 import {
   OpenAI,
@@ -669,7 +670,8 @@ const measureTextWidth = (
 };
 
 export function truncateText(text, maxWidth = 200) {
-  if (!isMobile()) {
+  const isMobileScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
+  if (!isMobileScreen) {
     return text;
   }
   if (!text) return text;

+ 4 - 4
web/src/helpers/utils.js

@@ -4,6 +4,7 @@ import React from 'react';
 import { toast } from 'react-toastify';
 import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
 import { TABLE_COMPACT_MODES_KEY } from '../constants';
+import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -67,9 +68,7 @@ export async function copy(text) {
   return okay;
 }
 
-export function isMobile() {
-  return window.innerWidth <= 600;
-}
+// isMobile 函数已移除,请改用 useIsMobile Hook
 
 let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
 let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
@@ -77,7 +76,8 @@ let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
 let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
 let showNoticeOptions = { autoClose: false };
 
-if (isMobile()) {
+const isMobileScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
+if (isMobileScreen) {
   showErrorOptions.position = 'top-center';
   // showErrorOptions.transition = 'flip';
 

+ 15 - 0
web/src/hooks/useIsMobile.js

@@ -0,0 +1,15 @@
+export const MOBILE_BREAKPOINT = 768;
+
+import { useSyncExternalStore } from 'react';
+
+export const useIsMobile = () => {
+  const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
+  return useSyncExternalStore(
+    (callback) => {
+      const mql = window.matchMedia(query);
+      mql.addEventListener('change', callback);
+      return () => mql.removeEventListener('change', callback);
+    },
+    () => window.matchMedia(query).matches,
+  );
+}; 

+ 22 - 0
web/src/hooks/useSidebarCollapsed.js

@@ -0,0 +1,22 @@
+import { useState, useCallback } from 'react';
+
+const KEY = 'default_collapse_sidebar';
+
+export const useSidebarCollapsed = () => {
+  const [collapsed, setCollapsed] = useState(() => localStorage.getItem(KEY) === 'true');
+
+  const toggle = useCallback(() => {
+    setCollapsed(prev => {
+      const next = !prev;
+      localStorage.setItem(KEY, next.toString());
+      return next;
+    });
+  }, []);
+
+  const set = useCallback((value) => {
+    setCollapsed(value);
+    localStorage.setItem(KEY, value.toString());
+  }, []);
+
+  return [collapsed, toggle, set];
+}; 

+ 16 - 0
web/src/index.css

@@ -14,6 +14,22 @@
 }
 
 /* ==================== 全局基础样式 ==================== */
+/* 侧边栏宽度相关的 CSS 变量,配合 .sidebar-collapsed 类和媒体查询实现响应式布局 */
+:root {
+  --sidebar-width: 180px;
+  /* 展开时宽度 */
+  --sidebar-width-collapsed: 60px;  /* 折叠后宽度,显示图标栏 */
+  /* 折叠后宽度 */
+  --sidebar-current-width: var(--sidebar-width);
+}
+
+/* 当 body 上存在 .sidebar-collapsed 类时,使用折叠宽度 */
+body.sidebar-collapsed {
+  --sidebar-current-width: var(--sidebar-width-collapsed);
+}
+
+/* 移除了在移动端强制设为 0 的限制,改由 React 控制是否渲染侧边栏以实现显示/隐藏 */
+
 body {
   font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
   color: var(--semi-color-text-0);

+ 1 - 4
web/src/index.js

@@ -6,7 +6,6 @@ import { UserProvider } from './context/User';
 import 'react-toastify/dist/ReactToastify.css';
 import { StatusProvider } from './context/Status';
 import { ThemeProvider } from './context/Theme';
-import { StyleProvider } from './context/Style/index.js';
 import PageLayout from './components/layout/PageLayout.js';
 import './i18n/i18n.js';
 import './index.css';
@@ -20,9 +19,7 @@ root.render(
       <UserProvider>
         <BrowserRouter>
           <ThemeProvider>
-            <StyleProvider>
-              <PageLayout />
-            </StyleProvider>
+            <PageLayout />
           </ThemeProvider>
         </BrowserRouter>
       </UserProvider>

+ 3 - 2
web/src/pages/Channel/EditChannel.js

@@ -3,12 +3,12 @@ import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import {
   API,
-  isMobile,
   showError,
   showInfo,
   showSuccess,
   verifyJSON,
 } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import { CHANNEL_OPTIONS } from '../../constants';
 import {
   SideSheet,
@@ -81,6 +81,7 @@ const EditChannel = (props) => {
   const channelId = props.editingChannel.id;
   const isEdit = channelId !== undefined;
   const [loading, setLoading] = useState(isEdit);
+  const isMobile = useIsMobile();
   const handleCancel = () => {
     props.handleClose();
   };
@@ -693,7 +694,7 @@ const EditChannel = (props) => {
         }
         bodyStyle={{ padding: '0' }}
         visible={props.visible}
-        width={isMobile() ? '100%' : 600}
+        width={isMobile ? '100%' : 600}
         footer={
           <div className="flex justify-end bg-white">
             <Space>

+ 5 - 3
web/src/pages/Detail/index.js

@@ -41,8 +41,9 @@ import { VChart } from '@visactor/react-vchart';
 import {
   API,
   isAdmin,
-  isMobile,
   showError,
+  showSuccess,
+  showWarning,
   timestamp2string,
   timestamp2string1,
   getQuotaWithUnit,
@@ -51,9 +52,9 @@ import {
   renderQuota,
   modelToColor,
   copy,
-  showSuccess,
   getRelativeTime
 } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import { UserContext } from '../../context/User/index.js';
 import { StatusContext } from '../../context/Status/index.js';
 import { useTranslation } from 'react-i18next';
@@ -66,6 +67,7 @@ const Detail = (props) => {
   // ========== Hooks - Navigation & Translation ==========
   const { t } = useTranslation();
   const navigate = useNavigate();
+  const isMobile = useIsMobile();
 
   // ========== Hooks - Refs ==========
   const formRef = useRef();
@@ -1150,7 +1152,7 @@ const Detail = (props) => {
         onOk={handleSearchConfirm}
         onCancel={handleCloseModal}
         closeOnEsc={true}
-        size={isMobile() ? 'full-width' : 'small'}
+        size={isMobile ? 'full-width' : 'small'}
         centered
       >
         <Form ref={formRef} layout='vertical' className="w-full">

+ 8 - 6
web/src/pages/Home/index.js

@@ -1,6 +1,7 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
-import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
+import { API, showError, copy, showSuccess } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import { API_ENDPOINTS } from '../../constants/common.constant';
 import { StatusContext } from '../../context/Status';
 import { marked } from 'marked';
@@ -18,6 +19,7 @@ const Home = () => {
   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
   const [homePageContent, setHomePageContent] = useState('');
   const [noticeVisible, setNoticeVisible] = useState(false);
+  const isMobile = useIsMobile();
   const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
   const docsLink = statusState?.status?.docs_link || '';
   const serverAddress = statusState?.status?.server_address || window.location.origin;
@@ -98,7 +100,7 @@ const Home = () => {
       <NoticeModal
         visible={noticeVisible}
         onClose={() => setNoticeVisible(false)}
-        isMobile={isMobile()}
+        isMobile={isMobile}
       />
       {homePageContentLoaded && homePageContent === '' ? (
         <div className="w-full overflow-x-hidden">
@@ -133,7 +135,7 @@ const Home = () => {
                       readonly
                       value={serverAddress}
                       className="flex-1 !rounded-full"
-                      size={isMobile() ? 'default' : 'large'}
+                      size={isMobile ? 'default' : 'large'}
                       suffix={
                         <div className="flex items-center gap-2">
                           <ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
@@ -160,13 +162,13 @@ const Home = () => {
                 {/* 操作按钮 */}
                 <div className="flex flex-row gap-4 justify-center items-center">
                   <Link to="/console">
-                    <Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
+                    <Button theme="solid" type="primary" size={isMobile ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
                       {t('获取密钥')}
                     </Button>
                   </Link>
                   {isDemoSiteMode && statusState?.status?.version ? (
                     <Button
-                      size={isMobile() ? "default" : "large"}
+                      size={isMobile ? "default" : "large"}
                       className="flex items-center !rounded-3xl px-6 py-2"
                       icon={<IconGithubLogo />}
                       onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
@@ -176,7 +178,7 @@ const Home = () => {
                   ) : (
                     docsLink && (
                       <Button
-                        size={isMobile() ? "default" : "large"}
+                        size={isMobile ? "default" : "large"}
                         className="flex items-center !rounded-3xl px-6 py-2"
                         icon={<IconFile />}
                         onClick={() => window.open(docsLink, '_blank')}

+ 14 - 25
web/src/pages/Playground/index.js

@@ -5,7 +5,7 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
 
 // Context
 import { UserContext } from '../../context/User/index.js';
-import { useStyle, styleActions } from '../../context/Style/index.js';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 
 // hooks
 import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
@@ -59,7 +59,8 @@ const generateAvatarDataUrl = (username) => {
 const Playground = () => {
   const { t } = useTranslation();
   const [userState] = useContext(UserContext);
-  const { state: styleState, dispatch: styleDispatch } = useStyle();
+  const isMobile = useIsMobile();
+  const styleState = { isMobile };
   const [searchParams] = useSearchParams();
 
   const state = usePlaygroundState();
@@ -321,19 +322,7 @@ const Playground = () => {
     }
   }, [searchParams, t]);
 
-  // 处理窗口大小变化
-  useEffect(() => {
-    const handleResize = () => {
-      const mobile = window.innerWidth < 768;
-      if (styleState.isMobile !== mobile) {
-        styleDispatch(styleActions.setMobile(mobile));
-      }
-    };
-
-    handleResize();
-    window.addEventListener('resize', handleResize);
-    return () => window.removeEventListener('resize', handleResize);
-  }, [styleState.isMobile, styleDispatch]);
+  // Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新
 
   // 构建预览payload
   useEffect(() => {
@@ -365,26 +354,26 @@ const Playground = () => {
   return (
     <div className="h-full bg-gray-50 mt-[64px]">
       <Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
-        {(showSettings || !styleState.isMobile) && (
+        {(showSettings || !isMobile) && (
           <Layout.Sider
             style={{
               background: 'transparent',
               borderRight: 'none',
               flexShrink: 0,
-              minWidth: styleState.isMobile ? '100%' : 320,
-              maxWidth: styleState.isMobile ? '100%' : 320,
-              height: styleState.isMobile ? 'auto' : 'calc(100vh - 66px)',
+              minWidth: isMobile ? '100%' : 320,
+              maxWidth: isMobile ? '100%' : 320,
+              height: isMobile ? 'auto' : 'calc(100vh - 66px)',
               overflow: 'auto',
-              position: styleState.isMobile ? 'fixed' : 'relative',
-              zIndex: styleState.isMobile ? 1000 : 1,
+              position: isMobile ? 'fixed' : 'relative',
+              zIndex: isMobile ? 1000 : 1,
               width: '100%',
               top: 0,
               left: 0,
               right: 0,
               bottom: 0,
             }}
-            width={styleState.isMobile ? '100%' : 320}
-            className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
+            width={isMobile ? '100%' : 320}
+            className={isMobile ? 'bg-white shadow-lg' : ''}
           >
             <OptimizedSettingsPanel
               inputs={inputs}
@@ -432,7 +421,7 @@ const Playground = () => {
             </div>
 
             {/* 调试面板 - 桌面端 */}
-            {showDebugPanel && !styleState.isMobile && (
+            {showDebugPanel && !isMobile && (
               <div className="w-96 flex-shrink-0 h-full">
                 <OptimizedDebugPanel
                   debugData={debugData}
@@ -446,7 +435,7 @@ const Playground = () => {
           </div>
 
           {/* 调试面板 - 移动端覆盖层 */}
-          {showDebugPanel && styleState.isMobile && (
+          {showDebugPanel && isMobile && (
             <div
               style={{
                 position: 'fixed',

+ 3 - 2
web/src/pages/Redemption/EditRedemption.js

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
 import {
   API,
   downloadTextAsFile,
-  isMobile,
   showError,
   showSuccess,
   renderQuota,
   renderQuotaWithPrompt,
 } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import {
   Button,
   Modal,
@@ -36,6 +36,7 @@ const EditRedemption = (props) => {
   const { t } = useTranslation();
   const isEdit = props.editingRedemption.id !== undefined;
   const [loading, setLoading] = useState(isEdit);
+  const isMobile = useIsMobile();
   const formApiRef = useRef(null);
 
   const getInitValues = () => ({
@@ -155,7 +156,7 @@ const EditRedemption = (props) => {
         }
         bodyStyle={{ padding: '0' }}
         visible={props.visiable}
-        width={isMobile() ? '100%' : 600}
+        width={isMobile ? '100%' : 600}
         footer={
           <div className="flex justify-end bg-white">
             <Space>

+ 5 - 2
web/src/pages/Setting/Ratio/UpstreamRatioSync.js

@@ -18,7 +18,8 @@ import {
   AlertTriangle,
   CheckCircle,
 } from 'lucide-react';
-import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
+import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
+import { useIsMobile } from '../../../hooks/useIsMobile.js';
 import { DEFAULT_ENDPOINT } from '../../../constants';
 import { useTranslation } from 'react-i18next';
 import {
@@ -28,6 +29,7 @@ import {
 import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
 
 function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
+  const isMobile = useIsMobile();
   const columns = [
     { title: t('渠道'), dataIndex: 'channel' },
     { title: t('模型'), dataIndex: 'model' },
@@ -49,7 +51,7 @@ function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
       visible={visible}
       onCancel={onCancel}
       onOk={onOk}
-      size={isMobile() ? 'full-width' : 'large'}
+      size={isMobile ? 'full-width' : 'large'}
     >
       <Table columns={columns} dataSource={items} pagination={false} size="small" />
     </Modal>
@@ -61,6 +63,7 @@ export default function UpstreamRatioSync(props) {
   const [modalVisible, setModalVisible] = useState(false);
   const [loading, setLoading] = useState(false);
   const [syncLoading, setSyncLoading] = useState(false);
+  const isMobile = useIsMobile();
 
   // 渠道选择相关
   const [allChannels, setAllChannels] = useState([]);

+ 3 - 2
web/src/pages/Token/EditToken.js

@@ -1,7 +1,6 @@
 import React, { useEffect, useState, useContext, useRef } from 'react';
 import {
   API,
-  isMobile,
   showError,
   showSuccess,
   timestamp2string,
@@ -9,6 +8,7 @@ import {
   renderQuotaWithPrompt,
   getModelCategories,
 } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import {
   Button,
   SideSheet,
@@ -38,6 +38,7 @@ const EditToken = (props) => {
   const { t } = useTranslation();
   const [statusState, statusDispatch] = useContext(StatusContext);
   const [loading, setLoading] = useState(false);
+  const isMobile = useIsMobile();
   const formApiRef = useRef(null);
   const [models, setModels] = useState([]);
   const [groups, setGroups] = useState([]);
@@ -277,7 +278,7 @@ const EditToken = (props) => {
       }
       bodyStyle={{ padding: '0' }}
       visible={props.visiable}
-      width={isMobile() ? '100%' : 600}
+      width={isMobile ? '100%' : 600}
       footer={
         <div className='flex justify-end bg-white'>
           <Space>

+ 4 - 2
web/src/pages/User/AddUser.js

@@ -1,5 +1,6 @@
 import React, { useState, useRef } from 'react';
-import { API, isMobile, showError, showSuccess } from '../../helpers';
+import { API, showError, showSuccess } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import {
   Button,
   SideSheet,
@@ -26,6 +27,7 @@ const AddUser = (props) => {
   const { t } = useTranslation();
   const formApiRef = useRef(null);
   const [loading, setLoading] = useState(false);
+  const isMobile = useIsMobile();
 
   const getInitValues = () => ({
     username: '',
@@ -67,7 +69,7 @@ const AddUser = (props) => {
         }
         bodyStyle={{ padding: '0' }}
         visible={props.visible}
-        width={isMobile() ? '100%' : 600}
+        width={isMobile ? '100%' : 600}
         footer={
           <div className="flex justify-end bg-white">
             <Space>

+ 3 - 2
web/src/pages/User/EditUser.js

@@ -2,12 +2,12 @@ import React, { useEffect, useState, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   API,
-  isMobile,
   showError,
   showSuccess,
   renderQuota,
   renderQuotaWithPrompt,
 } from '../../helpers';
+import { useIsMobile } from '../../hooks/useIsMobile.js';
 import {
   Button,
   Modal,
@@ -41,6 +41,7 @@ const EditUser = (props) => {
   const [loading, setLoading] = useState(true);
   const [addQuotaModalOpen, setIsModalOpen] = useState(false);
   const [addQuotaLocal, setAddQuotaLocal] = useState('');
+  const isMobile = useIsMobile();
   const [groupOptions, setGroupOptions] = useState([]);
   const formApiRef = useRef(null);
 
@@ -137,7 +138,7 @@ const EditUser = (props) => {
         }
         bodyStyle={{ padding: 0 }}
         visible={props.visible}
-        width={isMobile() ? '100%' : 600}
+        width={isMobile ? '100%' : 600}
         footer={
           <div className='flex justify-end bg-white'>
             <Space>