From d07d3b2c9950bab3764eba4e1f69b53558937e6b Mon Sep 17 00:00:00 2001 From: jialin Date: Wed, 1 Jul 2026 15:21:35 +0800 Subject: [PATCH] refactor(layout): adopt header-slot page container across pages --- src/layouts/index.tsx | 34 +--- src/pages/_components/page-box.tsx | 151 +++++++++++++----- src/pages/_components/styles/page-box.less | 30 ++++ src/pages/benchmark/details.tsx | 11 +- .../cluster-management/cluster-detail.tsx | 13 +- src/pages/llmodels/index.tsx | 19 ++- src/pages/playground/chat/index.tsx | 57 +++---- src/pages/playground/embedding/index.tsx | 25 ++- src/pages/playground/images/index.tsx | 24 +-- src/pages/playground/rerank/index.tsx | 25 ++- src/pages/playground/speech/index.tsx | 51 +++--- src/pages/playground/video/index.tsx | 30 ++-- 12 files changed, 264 insertions(+), 206 deletions(-) diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 2a4c37d2..e49b03ba 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -52,21 +52,6 @@ import { ExtraContent } from './extraRender'; import { patchRoutes } from './runtime'; import SiderMenu from './sider-menu'; -// Pages that use the page container in the page -const NO_CONTAINER_PAGES = [ - 'chat', - 'rerank', - 'embedding', - 'speech', - 'image', - 'text2images', - 'clusterDetail', - 'clusterCreate', - 'benchmarkDetail', - 'deployment', - 'video' -]; - const CHECK_RESOURCE_PATH = [ '/resources/workers', '/resources/clusters/list', @@ -269,11 +254,6 @@ export default (props: any) => { [location.pathname] ); - const isNoContainerPage = useMemo(() => { - // @ts-ignore - return NO_CONTAINER_PAGES.includes(matchedRoute?.name as string); - }, [matchedRoute]); - const collapsed = useMemo(() => { return userSettings.collapsed || false; }, [userSettings.collapsed]); @@ -469,15 +449,11 @@ export default (props: any) => { unAccessible={runtimeConfig?.unAccessible} noAccessible={runtimeConfig?.noAccessible} > - {isNoContainerPage ? ( - - ) : ( - -
- -
-
- )} + +
+ +
+
{NoResourceModal} diff --git a/src/pages/_components/page-box.tsx b/src/pages/_components/page-box.tsx index dd2e7079..b0ae2279 100644 --- a/src/pages/_components/page-box.tsx +++ b/src/pages/_components/page-box.tsx @@ -7,11 +7,61 @@ import { import { useOverlayScroller } from '@gpustack/core-ui'; import { Divider } from 'antd'; import classNames from 'classnames'; -import { useContext, useEffect, useRef } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; +import { createPortal } from 'react-dom'; import pageBoxCss from './styles/page-box.less'; const paddingInlinePageContainerContent = 24; +type HeaderSlotContextValue = { + leftEl: HTMLElement | null; + rightEl: HTMLElement | null; + setContentStyle: (style: React.CSSProperties | undefined) => () => void; +}; + +const HeaderSlotContext = createContext(null); + +// Pages render / as part of their JSX; the children +// are portaled into the layout-owned PageContainerInner header bar so the +// shell never unmounts between routes. Visibility of the default title / +// right divider is driven by CSS (`:empty` / `:not(:empty)`) on the portal +// target, so they react synchronously to DOM mutations — no React state, no +// re-renders, no inter-frame flicker. +export const HeaderLeft: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const ctx = useContext(HeaderSlotContext); + return ctx?.leftEl ? createPortal(children, ctx.leftEl) : null; +}; + +export const HeaderRight: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const ctx = useContext(HeaderSlotContext); + return ctx?.rightEl ? createPortal(children, ctx.rightEl) : null; +}; + +// Pages that need to override the layout-owned content wrapper style +// (e.g. playground pages that want zero padding) call this hook in render. +// Applied synchronously via useLayoutEffect to avoid first-paint flicker. +export const usePageContentStyle = (style?: React.CSSProperties): void => { + const ctx = useContext(HeaderSlotContext); + const stable = JSON.stringify(style ?? null); + useLayoutEffect(() => { + if (!ctx) return; + return ctx.setContentStyle(style); + }, [stable, ctx?.setContentStyle]); +}; + export const PageContainerInner: React.FC< PageContainerProps & { leftContent?: React.ReactNode; @@ -26,6 +76,11 @@ export const PageContainerInner: React.FC< }); const pageContext = useContext(RouteContext); const contentWrapperRef = useRef(null); + const [leftEl, setLeftEl] = useState(null); + const [rightEl, setRightEl] = useState(null); + const [contentStyleOverride, setContentStyleOverride] = useState< + React.CSSProperties | undefined + >(undefined); useEffect(() => { if (contentWrapperRef.current) { @@ -34,43 +89,67 @@ export const PageContainerInner: React.FC< } }, [initialize, contentWrapperRef]); + const setContentStyle = useCallback( + (style: React.CSSProperties | undefined) => { + setContentStyleOverride(style); + return () => setContentStyleOverride(undefined); + }, + [] + ); + + const slotValue = useMemo( + () => ({ + leftEl, + rightEl, + setContentStyle + }), + [leftEl, rightEl, setContentStyle] + ); + return ( -
- -
-
- {leftContent || pageContext.title} -
-
- {rightContent && ( -
- {rightContent} - -
- )} - -
-
-
+
+ - {children} -
- -
+
+
+
+ {leftContent} + + {pageContext.title} + +
+
+
+ {rightContent} + + +
+
+
+ {children} +
+ +
+ ); }; diff --git a/src/pages/_components/styles/page-box.less b/src/pages/_components/styles/page-box.less index 96df205b..1629c2af 100644 --- a/src/pages/_components/styles/page-box.less +++ b/src/pages/_components/styles/page-box.less @@ -70,14 +70,44 @@ height: 56px; border-bottom: 1px solid var(--ant-color-split); + // `.left` claims all the space between the title's left edge and `.right`. + // Because the container width is fixed regardless of inner content, the + // right side never shifts during route transitions and the content inside + // .left is anchored to the left edge — no horizontal jitter. .left { display: flex; align-items: center; + flex: 1 1 auto; + min-width: 0; } .right { display: flex; align-items: center; + flex-shrink: 0; + } + + .leftSlot, + .rightSlot { + display: contents; + } + + .defaultTitle { + display: inline-flex; + align-items: center; + } + + // Portal-driven visibility: when a page's HeaderLeft / HeaderRight + // portal target has children, hide the default title / show the divider. + // Using `:empty` (rather than React state) keeps visibility synchronous + // with the DOM mutation, so there is no inter-frame flicker on route + // transitions. + .leftSlot:not(:empty) ~ .defaultTitle { + display: none; + } + + .rightSlot:empty ~ .divider { + display: none; } } } diff --git a/src/pages/benchmark/details.tsx b/src/pages/benchmark/details.tsx index 562a796d..79294699 100644 --- a/src/pages/benchmark/details.tsx +++ b/src/pages/benchmark/details.tsx @@ -2,7 +2,7 @@ import { BaseSelect, DeleteModal } from '@gpustack/core-ui'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import React, { useEffect, useRef } from 'react'; -import { PageContainerInner } from '../_components/page-box'; +import { HeaderLeft } from '../_components/page-box'; import PageBreadcrumb from '../_components/page-breadcrumb'; import { deleteBenchmark } from './apis'; import DetailContent from './components/detail-content'; @@ -122,9 +122,10 @@ const Details: React.FC = () => { }, [id]); return ( - } - > + <> + + + { onCancel={closeViewLogsModal} > - + ); }; diff --git a/src/pages/cluster-management/cluster-detail.tsx b/src/pages/cluster-management/cluster-detail.tsx index 11045e1f..657d71f3 100644 --- a/src/pages/cluster-management/cluster-detail.tsx +++ b/src/pages/cluster-management/cluster-detail.tsx @@ -6,7 +6,7 @@ import { IconFont } from '@gpustack/core-ui'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { Tabs, type TabsProps } from 'antd'; import { useAtomValue } from 'jotai'; -import { PageContainerInner } from '../_components/page-box'; +import { HeaderLeft } from '../_components/page-box'; import PageBreadcrumb from '../_components/page-breadcrumb'; import ClusterBasic from './components/detail/cluster-basic'; import ClusterSystemLoad from './components/detail/cluster-system-load'; @@ -46,11 +46,10 @@ const ClusterDetailModal = () => { ) ?? []) as TabItem[]; return ( - - }} - > + <> + + + { ...extraTabs ]} /> - + ); }; diff --git a/src/pages/llmodels/index.tsx b/src/pages/llmodels/index.tsx index aeb4287b..c0eb4184 100644 --- a/src/pages/llmodels/index.tsx +++ b/src/pages/llmodels/index.tsx @@ -1,4 +1,7 @@ -import { PageContainerInner } from '@/pages/_components/page-box'; +import { + HeaderLeft, + usePageContentStyle +} from '@/pages/_components/page-box'; import { IconFont } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; @@ -105,15 +108,11 @@ const LLModels: React.FC = () => { }; }, [activeKey]); + usePageContentStyle({ padding: 0 }); + return ( - + <> + {title} { activeKey={activeKey} > - + ); }; diff --git a/src/pages/playground/chat/index.tsx b/src/pages/playground/chat/index.tsx index 0ff35d65..a5b67ec8 100644 --- a/src/pages/playground/chat/index.tsx +++ b/src/pages/playground/chat/index.tsx @@ -6,11 +6,14 @@ import { MessageOutlined, OneToOneOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import { Segmented, Tabs, TabsProps } from 'antd'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderLeft, + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import MultipleChat from '../components/multiple-chat'; import ViewCodeButtons from '../components/view-code-buttons'; @@ -148,53 +151,43 @@ const Playground: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - +
{intl.formatMessage({ id: 'menu.playground.chat' })} - { - setActiveKey(key)} - > - } + setActiveKey(key)} + >
- } - rightContent={ +
+ - } - > +
-
+ ); }; diff --git a/src/pages/playground/embedding/index.tsx b/src/pages/playground/embedding/index.tsx index 6891ae31..3d4a213a 100644 --- a/src/pages/playground/embedding/index.tsx +++ b/src/pages/playground/embedding/index.tsx @@ -1,12 +1,13 @@ import HotKeys from '@/config/hotkeys'; import { modelCategoriesMap } from '@/pages/llmodels/config'; -import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import ViewCodeButtons from '../components/view-code-buttons'; import useCollapseLayout from '../hooks/use-collapse-layout'; @@ -14,7 +15,6 @@ import '../style/play-ground.less'; import GroundEmbedding from './page'; const PlaygroundEmbedding: React.FC = () => { - const intl = useIntl(); const groundLeftRef = useRef(null); const [modelList, setModelList] = useState[]>([]); const [loaded, setLoaded] = useState(false); @@ -76,23 +76,18 @@ const PlaygroundEmbedding: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - + - } - > +
{ >
-
+ ); }; diff --git a/src/pages/playground/images/index.tsx b/src/pages/playground/images/index.tsx index d495b462..fc24a50b 100644 --- a/src/pages/playground/images/index.tsx +++ b/src/pages/playground/images/index.tsx @@ -6,11 +6,14 @@ import { DiffOutlined, HighlightOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import { Segmented, Tabs, TabsProps } from 'antd'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderLeft, + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import ViewCodeButtons from '../components/view-code-buttons'; import '../style/play-ground.less'; @@ -154,9 +157,11 @@ const TextToImages: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - +
{intl.formatMessage({ id: 'menu.playground.text2images' })} @@ -173,24 +178,21 @@ const TextToImages: React.FC = () => { onChange={(key) => setActiveKey(key)} >
- } - rightContent={ +
+ - } - styles={{ containerWrapper: { padding: 0 } }} - className={classNames('playground-container chat')} - > +
-
+ ); }; diff --git a/src/pages/playground/rerank/index.tsx b/src/pages/playground/rerank/index.tsx index d3bd82a7..619455a4 100644 --- a/src/pages/playground/rerank/index.tsx +++ b/src/pages/playground/rerank/index.tsx @@ -1,12 +1,13 @@ import HotKeys from '@/config/hotkeys'; import { modelCategoriesMap } from '@/pages/llmodels/config'; -import { useIntl } from '@umijs/max'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import ViewCodeButtons from '../components/view-code-buttons'; import useCollapseLayout from '../hooks/use-collapse-layout'; @@ -14,7 +15,6 @@ import '../style/play-ground.less'; import GroundReranker from './page'; const PlaygroundRerank: React.FC = () => { - const intl = useIntl(); const groundRerankerRef = useRef(null); const [rerankerModelList, setRerankerModelList] = useState< Global.BaseOption[] @@ -80,23 +80,18 @@ const PlaygroundRerank: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - + - } - > +
{ >
-
+ ); }; diff --git a/src/pages/playground/speech/index.tsx b/src/pages/playground/speech/index.tsx index e40e9f49..977ddb62 100644 --- a/src/pages/playground/speech/index.tsx +++ b/src/pages/playground/speech/index.tsx @@ -7,11 +7,14 @@ import { IconFont } from '@gpustack/core-ui'; import { useIntl, useSearchParams } from '@umijs/max'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; import { Segmented, Tabs, TabsProps } from 'antd'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderLeft, + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import ViewCodeButtons from '../components/view-code-buttons'; import '../style/play-ground.less'; @@ -194,48 +197,42 @@ const Playground: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - +
{intl.formatMessage({ id: 'menu.playground.speech' })} - { - setActiveKey(key)} - > - } + setActiveKey(key)} + >
- } - rightContent={ +
+ - } - styles={{ containerWrapper: { padding: 0 } }} - className={classNames('playground-container', { - compare: activeKey === 'compare', - chat: activeKey !== 'compare' - })} - > +
-
+ ); }; diff --git a/src/pages/playground/video/index.tsx b/src/pages/playground/video/index.tsx index 4f1a0d68..1af7af16 100644 --- a/src/pages/playground/video/index.tsx +++ b/src/pages/playground/video/index.tsx @@ -1,14 +1,13 @@ import HotKeys from '@/config/hotkeys'; -import { ExtraContent } from '@/layouts/extraRender'; import { modelCategoriesMap } from '@/pages/llmodels/config'; -import { useIntl } from '@umijs/max'; import useMemoizedFn from 'ahooks/lib/useMemoizedFn'; -import { Divider } from 'antd'; -import classNames from 'classnames'; import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PageContainerInner } from '../../_components/page-box'; +import { + HeaderRight, + usePageContentStyle +} from '../../_components/page-box'; import { queryModelsList } from '../apis'; import ViewCodeButtons from '../components/view-code-buttons'; import useCollapseLayout from '../hooks/use-collapse-layout'; @@ -16,7 +15,6 @@ import '../style/play-ground.less'; import GroundVideo from './page'; const PlaygroundRerank: React.FC = () => { - const intl = useIntl(); const groundVideoRef = useRef(null); const [modelList, setModelList] = useState[]>([]); const [loaded, setLoaded] = useState(false); @@ -78,24 +76,18 @@ const PlaygroundRerank: React.FC = () => { } ); + usePageContentStyle({ padding: 0 }); + return ( - + , - , - - ]} - > + > +
{ >
-
+ ); };