diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js
index 4a3a0d18..998c2bf3 100644
--- a/web/src/components/settings/ChannelSelectorModal.js
+++ b/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={
}
- size={isMobile() ? 'full-width' : 'large'}
+ size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js
index 3299f61f..fba5db79 100644
--- a/web/src/components/table/ChannelsTable.js
+++ b/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'}
>
{currentTestChannel && (
diff --git a/web/src/context/Style/index.js b/web/src/context/Style/index.js
deleted file mode 100644
index 7bfe0ef7..00000000
--- a/web/src/context/Style/index.js
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-};
-
-/**
- * 自定义 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
- }),
-};
diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js
index 12aa01b3..34ba78d7 100644
--- a/web/src/helpers/render.js
+++ b/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;
diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js
index 68a05846..6c4f1275 100644
--- a/web/src/helpers/utils.js
+++ b/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
;
@@ -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';
diff --git a/web/src/hooks/useIsMobile.js b/web/src/hooks/useIsMobile.js
new file mode 100644
index 00000000..9f34b58e
--- /dev/null
+++ b/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,
+ );
+};
\ No newline at end of file
diff --git a/web/src/hooks/useSidebarCollapsed.js b/web/src/hooks/useSidebarCollapsed.js
new file mode 100644
index 00000000..2982ff9b
--- /dev/null
+++ b/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];
+};
\ No newline at end of file
diff --git a/web/src/index.css b/web/src/index.css
index 66bc64d3..791b853e 100644
--- a/web/src/index.css
+++ b/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);
diff --git a/web/src/index.js b/web/src/index.js
index ef299ea2..e75ccf97 100644
--- a/web/src/index.js
+++ b/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(
-
-
-
+
diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js
index d5682b0e..ff66d65f 100644
--- a/web/src/pages/Channel/EditChannel.js
+++ b/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={
diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js
index d560177c..2c0cf6a8 100644
--- a/web/src/pages/Detail/index.js
+++ b/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
>