refactor: layout logic to enhance front-end responsiveness

Merge pull request #1377 from QuantumNous/refactor/layout
This commit is contained in:
同語
2025-07-16 04:53:15 +08:00
committed by GitHub
35 changed files with 277 additions and 432 deletions

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
@@ -7,22 +7,28 @@ import Loading from '../common/Loading';
const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const [, userDispatch] = useContext(UserContext);
const navigate = useNavigate();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState(t('处理中...'));
// 最大重试次数
const MAX_RETRIES = 3;
let navigate = useNavigate();
const sendCode = async (code, state, retry = 0) => {
try {
const { data: resData } = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = resData;
if (!success) {
throw new Error(message || 'OAuth2 callback error');
}
const sendCode = async (code, state, count) => {
const res = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/setting');
navigate('/console/personal');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
@@ -31,27 +37,34 @@ const OAuth2Callback = (props) => {
showSuccess(t('登录成功!'));
navigate('/console/token');
}
} else {
showError(message);
if (count === 0) {
setPrompt(t('操作失败,重定向至登录界面中...'));
navigate('/console/setting'); // in case this is failed to bind GitHub
return;
} catch (error) {
if (retry < MAX_RETRIES) {
// 递增的退避等待
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
return sendCode(code, state, retry + 1);
}
count++;
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
// 重试次数耗尽,提示错误并返回设置页面
showError(error.message || t('授权失败'));
navigate('/console/personal');
}
};
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
const code = searchParams.get('code');
const state = searchParams.get('state');
// 参数缺失直接返回
if (!code) {
showError(t('未获取到授权码'));
navigate('/console/personal');
return;
}
sendCode(code, state);
}, []);
return <Loading prompt={prompt} />;
return <Loading />;
};
export default OAuth2Callback;

View File

@@ -1,22 +1,14 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const Loading = ({ prompt: name = '', size = 'large' }) => {
const { t } = useTranslation();
const Loading = ({ size = 'small' }) => {
return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size={size}
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('{{name}}', { name }) : t('加载中...')}
</span>
</div>
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
<Spin
size={size}
spinning={true}
/>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import { useSetTheme, useTheme } from '../../context/Theme/index.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);
@@ -45,6 +47,7 @@ const HeaderBar = () => {
const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const loadingStartRef = useRef(Date.now());
const systemName = getSystemName();
const logo = getLogo();
@@ -194,11 +197,15 @@ const HeaderBar = () => {
}, [i18n]);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
if (statusState?.status !== undefined) {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const timer = setTimeout(() => {
setIsLoading(false);
}, remaining);
return () => clearTimeout(timer);
}
}, [statusState?.status]);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
@@ -207,7 +214,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 +300,7 @@ const HeaderBar = () => {
placeholder={
<Skeleton.Title
active
style={{ width: styleState.isMobile ? 15 : 50, height: 12 }}
style={{ width: isMobile ? 15 : 50, height: 12 }}
/>
}
/>
@@ -388,7 +395,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 +443,7 @@ const HeaderBar = () => {
<NoticeModal
visible={noticeVisible}
onClose={handleNoticeClose}
isMobile={styleState.isMobile}
isMobile={isMobile}
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
unreadKeys={getUnreadKeys()}
/>
@@ -447,18 +454,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);

View File

@@ -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';
@@ -14,9 +15,11 @@ import { useLocation } from 'react-router-dom';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState } = useStyle();
const [, userDispatch] = useContext(UserContext);
const [, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, , setCollapsed] = useSidebarCollapsed();
const [drawerOpen, setDrawerOpen] = useState(false);
const { i18n } = useTranslation();
const location = useLocation();
@@ -26,6 +29,15 @@ const PageLayout = () => {
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
setCollapsed(false);
}
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
@@ -63,7 +75,6 @@ const PageLayout = () => {
linkElement.href = logo;
}
}
// 从localStorage获取上次使用的语言
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
i18n.changeLanguage(savedLang);
@@ -76,7 +87,7 @@ const PageLayout = () => {
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: styleState.isMobile ? 'visible' : 'hidden',
overflow: isMobile ? 'visible' : 'hidden',
}}
>
<Header
@@ -90,16 +101,16 @@ 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',
@@ -109,21 +120,15 @@ const PageLayout = () => {
border: 'none',
paddingRight: '0',
height: 'calc(100vh - 64px)',
width: 'var(--sidebar-current-width)',
}}
>
<SiderBar />
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
</Sider>
)}
<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 +137,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',
}}
>

View File

@@ -3,7 +3,7 @@ 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 { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
import {
isAdmin,
isRoot,
@@ -13,7 +13,7 @@ import {
import {
Nav,
Divider,
Tooltip,
Button,
} from '@douyinfe/semi-ui';
const routerMap = {
@@ -34,12 +34,11 @@ const routerMap = {
personal: '/console/personal',
};
const SiderBar = () => {
const SiderBar = ({ onNavigate = () => { } }) => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
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 +216,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 +326,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"
@@ -363,6 +347,7 @@ const SiderBar = () => {
<Link
style={{ textDecoration: 'none' }}
to={to}
onClick={onNavigate}
>
{itemElement}
</Link>
@@ -383,7 +368,7 @@ const SiderBar = () => {
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
@@ -392,7 +377,7 @@ const SiderBar = () => {
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
@@ -403,7 +388,7 @@ const SiderBar = () => {
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
@@ -414,7 +399,7 @@ const SiderBar = () => {
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
@@ -422,24 +407,25 @@ const SiderBar = () => {
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
<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%' }}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
</div>
</div>
);

View File

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

View File

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

View File

@@ -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
}),
};

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
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,
() => false,
);
};

View File

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

View File

@@ -179,7 +179,6 @@
"注销": "Logout",
"登录": "Sign in",
"注册": "Sign up",
"加载{name}中...": "Loading {name}...",
"未登录或登录已过期,请重新登录!": "Not logged in or session expired. Please login again!",
"用户登录": "User Login",
"密码": "Password",
@@ -933,7 +932,6 @@
"更新令牌后需等待几分钟生效": "It will take a few minutes to take effect after updating the token.",
"一小时": "One hour",
"新建数量": "New quantity",
"加载失败,请稍后重试": "Loading failed, please try again later",
"未设置": "Not set",
"API文档": "API documentation",
"不是合法的 JSON 字符串": "Not a valid JSON string",

View File

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

View File

@@ -6,11 +6,18 @@ 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';
// 欢迎信息(二次开发者不准将此移除)
// Welcome message (Secondary developers are not allowed to remove this)
if (typeof window !== 'undefined') {
console.log('%cWe ❤ NewAPI%c Github: https://github.com/QuantumNous/new-api',
'color: #10b981; font-weight: bold; font-size: 24px;',
'color: inherit; font-size: 14px;');
}
// initialization
const root = ReactDOM.createRoot(document.getElementById('root'));
@@ -18,11 +25,14 @@ root.render(
<React.StrictMode>
<StatusProvider>
<UserProvider>
<BrowserRouter>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<ThemeProvider>
<StyleProvider>
<PageLayout />
</StyleProvider>
<PageLayout />
</ThemeProvider>
</BrowserRouter>
</UserProvider>

View File

@@ -105,7 +105,7 @@ const About = () => {
);
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
{aboutLoaded && about === '' ? (
<div className="flex justify-center items-center h-screen p-8">
<Empty

View File

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

View File

@@ -3,7 +3,7 @@ import ChannelsTable from '../../components/table/ChannelsTable';
const File = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<ChannelsTable />
</div>
);

View File

@@ -17,7 +17,7 @@ const chat2page = () => {
}
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<h3>正在加载请稍候...</h3>
</div>
);

View File

@@ -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();
@@ -1118,7 +1120,7 @@ const Detail = (props) => {
}, []);
return (
<div className="bg-gray-50 h-full mt-[64px]">
<div className="bg-gray-50 h-full mt-[64px] px-2">
<div className="flex items-center justify-between mb-4">
<h2
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
@@ -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">

View File

@@ -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')}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import LogsTable from '../../components/table/LogsTable';
const Token = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<LogsTable />
</div>
);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import MjLogsTable from '../../components/table/MjLogsTable';
const Midjourney = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<MjLogsTable />
</div>
);

View File

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

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ModelPricing from '../../components/table/ModelPricing.js';
const Pricing = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<ModelPricing />
</div>
);

View File

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

View File

@@ -3,7 +3,7 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
const Redemption = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<RedemptionsTable />
</div>
);

View File

@@ -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([]);

View File

@@ -150,7 +150,7 @@ const Setting = () => {
}
}, [location.search]);
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<Layout>
<Layout.Content>
<Tabs

View File

@@ -2,7 +2,7 @@ import React from 'react';
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
const Task = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<TaskLogsTable />
</div>
);

View File

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

View File

@@ -3,7 +3,7 @@ import TokensTable from '../../components/table/TokensTable';
const Token = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<TokensTable />
</div>
);

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import UsersTable from '../../components/table/UsersTable';
const User = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<UsersTable />
</div>
);