refactor(layout): adopt header-slot page container across pages

This commit is contained in:
jialin
2026-07-01 19:11:47 +08:00
committed by jialin
parent 9d0a5d01b0
commit d07d3b2c99
12 changed files with 264 additions and 206 deletions
+5 -29
View File
@@ -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 ? (
<Outlet />
) : (
<PageContainerInner>
<div>
<Outlet />
</div>
</PageContainerInner>
)}
<PageContainerInner>
<div>
<Outlet />
</div>
</PageContainerInner>
</Exception>
</div>
{NoResourceModal}
+115 -36
View File
@@ -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<HeaderSlotContextValue | null>(null);
// Pages render <HeaderLeft> / <HeaderRight> 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<HTMLDivElement>(null);
const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
const [rightEl, setRightEl] = useState<HTMLDivElement | null>(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<HeaderSlotContextValue>(
() => ({
leftEl,
rightEl,
setContentStyle
}),
[leftEl, rightEl, setContentStyle]
);
return (
<div className={pageBoxCss.containerWrapper}>
<PageContainer
{...rest}
fixedHeader={false}
title={false}
pageHeaderRender={false}
style={{
flex: 1
}}
token={{
paddingInlinePageContainerContent: paddingInlinePageContainerContent
}}
>
<div className={pageBoxCss.title}>
<div className={pageBoxCss.left}>
{leftContent || pageContext.title}
</div>
<div className={pageBoxCss.right}>
{rightContent && (
<div>
{rightContent}
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
</div>
)}
<ExtraContent />
</div>
</div>
<div
className={classNames(pageBoxCss.contentWrapper)}
style={styles?.containerWrapper}
ref={contentWrapperRef}
<HeaderSlotContext.Provider value={slotValue}>
<div className={pageBoxCss.containerWrapper}>
<PageContainer
{...rest}
fixedHeader={false}
title={false}
pageHeaderRender={false}
style={{
flex: 1
}}
token={{
paddingInlinePageContainerContent: paddingInlinePageContainerContent
}}
>
{children}
</div>
</PageContainer>
</div>
<div className={pageBoxCss.title}>
<div className={pageBoxCss.left}>
<div ref={setLeftEl} className={pageBoxCss.leftSlot} />
{leftContent}
<span className={pageBoxCss.defaultTitle}>
{pageContext.title}
</span>
</div>
<div className={pageBoxCss.right}>
<div ref={setRightEl} className={pageBoxCss.rightSlot} />
{rightContent}
<Divider
className={pageBoxCss.divider}
orientation="vertical"
style={{ margin: '0 16px' }}
/>
<ExtraContent />
</div>
</div>
<div
className={classNames(pageBoxCss.contentWrapper)}
style={{ ...styles?.containerWrapper, ...contentStyleOverride }}
ref={contentWrapperRef}
>
{children}
</div>
</PageContainer>
</div>
</HeaderSlotContext.Provider>
);
};
@@ -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;
}
}
}
+6 -5
View File
@@ -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 (
<PageContainerInner
leftContent={<PageBreadcrumb items={breadcrumbItems} />}
>
<>
<HeaderLeft>
<PageBreadcrumb items={breadcrumbItems} />
</HeaderLeft>
<DetailContext.Provider
value={{
detailData: detailData || {},
@@ -164,7 +165,7 @@ const Details: React.FC = () => {
onCancel={closeViewLogsModal}
></ViewLogsModal>
<DeleteModal ref={modalRef}></DeleteModal>
</PageContainerInner>
</>
);
};
@@ -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 (
<PageContainerInner
header={{
title: <PageBreadcrumb items={breadcrumbItems} />
}}
>
<>
<HeaderLeft>
<PageBreadcrumb items={breadcrumbItems} />
</HeaderLeft>
<ClusterBasic clusterId={Number(id)}></ClusterBasic>
<ClusterSystemLoad clusterId={Number(id)}></ClusterSystemLoad>
<Tabs
@@ -75,7 +74,7 @@ const ClusterDetailModal = () => {
...extraTabs
]}
/>
</PageContainerInner>
</>
);
};
+9 -10
View File
@@ -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 (
<PageContainerInner
leftContent={title}
styles={{
containerWrapper: {
padding: 0
}
}}
>
<>
<HeaderLeft>{title}</HeaderLeft>
<DeploymentsContext.Provider
value={{
generateFormValues,
@@ -138,7 +137,7 @@ const LLModels: React.FC = () => {
activeKey={activeKey}
></Tabs>
</DeploymentsContext.Provider>
</PageContainerInner>
</>
);
};
+25 -32
View File
@@ -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 (
<PageContainerInner
className={classNames('playground-container', {
compare: activeKey === 'compare',
chat: activeKey !== 'compare'
})}
styles={{
containerWrapper: {
padding: 0
}
}}
leftContent={
<>
<HeaderLeft>
<div className="flex items-center">
<span className="font-600 flex-center">
{intl.formatMessage({ id: 'menu.playground.chat' })}
</span>
{
<Segmented
shape="round"
style={{
backgroundColor: 'var(--ant-color-fill-secondary)',
fontSize: 13
}}
size="middle"
className="m-l-24 font-400"
options={optionsList}
value={activeKey}
onChange={(key) => setActiveKey(key)}
></Segmented>
}
<Segmented
shape="round"
style={{
backgroundColor: 'var(--ant-color-fill-secondary)',
fontSize: 13
}}
size="middle"
className="m-l-24 font-400"
options={optionsList}
value={activeKey}
onChange={(key) => setActiveKey(key)}
></Segmented>
</div>
}
rightContent={
</HeaderLeft>
<HeaderRight>
<ViewCodeButtons
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
activeKey={activeKey}
key="view-code-buttons"
/>
}
>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<Tabs items={items} activeKey={activeKey}></Tabs>
</div>
</div>
</PageContainerInner>
</>
);
};
+10 -15
View File
@@ -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<any>(null);
const [modelList, setModelList] = useState<Global.BaseOption<string>[]>([]);
const [loaded, setLoaded] = useState(false);
@@ -76,23 +76,18 @@ const PlaygroundEmbedding: React.FC = () => {
}
);
usePageContentStyle({ padding: 0 });
return (
<PageContainerInner
className={classNames('playground-container chat')}
styles={{
containerWrapper: {
padding: 0
}
}}
rightContent={
<>
<HeaderRight>
<ViewCodeButtons
activeKey=""
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
key="view-code-buttons"
/>
}
>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<GroundEmbedding
@@ -102,7 +97,7 @@ const PlaygroundEmbedding: React.FC = () => {
></GroundEmbedding>
</div>
</div>
</PageContainerInner>
</>
);
};
+13 -11
View File
@@ -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 (
<PageContainerInner
leftContent={
<>
<HeaderLeft>
<div className="flex items-center">
<span className="font-600 flex-center">
{intl.formatMessage({ id: 'menu.playground.text2images' })}
@@ -173,24 +178,21 @@ const TextToImages: React.FC = () => {
onChange={(key) => setActiveKey(key)}
></Segmented>
</div>
}
rightContent={
</HeaderLeft>
<HeaderRight>
<ViewCodeButtons
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
activeKey={activeKey}
key="view-code-buttons"
></ViewCodeButtons>
}
styles={{ containerWrapper: { padding: 0 } }}
className={classNames('playground-container chat')}
>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<Tabs items={items} activeKey={activeKey}></Tabs>
</div>
</div>
</PageContainerInner>
</>
);
};
+10 -15
View File
@@ -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<any>(null);
const [rerankerModelList, setRerankerModelList] = useState<
Global.BaseOption<string>[]
@@ -80,23 +80,18 @@ const PlaygroundRerank: React.FC = () => {
}
);
usePageContentStyle({ padding: 0 });
return (
<PageContainerInner
className={classNames('playground-container chat')}
styles={{
containerWrapper: {
padding: 0
}
}}
rightContent={
<>
<HeaderRight>
<ViewCodeButtons
activeKey=""
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
key="view-code-buttons"
></ViewCodeButtons>
}
>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<GroundReranker
@@ -106,7 +101,7 @@ const PlaygroundRerank: React.FC = () => {
></GroundReranker>
</div>
</div>
</PageContainerInner>
</>
);
};
+24 -27
View File
@@ -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 (
<PageContainerInner
leftContent={
<>
<HeaderLeft>
<div className="flex items-center">
<span className="font-600 flex-center">
{intl.formatMessage({ id: 'menu.playground.speech' })}
</span>
{
<Segmented
shape="round"
style={{
backgroundColor: 'var(--ant-color-fill-secondary)'
}}
size="middle"
className="m-l-24 font-400"
options={optionsList}
value={activeKey}
onChange={(key) => setActiveKey(key)}
></Segmented>
}
<Segmented
shape="round"
style={{
backgroundColor: 'var(--ant-color-fill-secondary)'
}}
size="middle"
className="m-l-24 font-400"
options={optionsList}
value={activeKey}
onChange={(key) => setActiveKey(key)}
></Segmented>
</div>
}
rightContent={
</HeaderLeft>
<HeaderRight>
<ViewCodeButtons
activeKey=""
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
key="view-code-buttons"
></ViewCodeButtons>
}
styles={{ containerWrapper: { padding: 0 } }}
className={classNames('playground-container', {
compare: activeKey === 'compare',
chat: activeKey !== 'compare'
})}
>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<Tabs items={items} activeKey={activeKey}></Tabs>
</div>
</div>
</PageContainerInner>
</>
);
};
+11 -19
View File
@@ -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<any>(null);
const [modelList, setModelList] = useState<Global.BaseOption<string>[]>([]);
const [loaded, setLoaded] = useState(false);
@@ -78,24 +76,18 @@ const PlaygroundRerank: React.FC = () => {
}
);
usePageContentStyle({ padding: 0 });
return (
<PageContainerInner
className={classNames('playground-container chat')}
extra={[
<>
<HeaderRight>
<ViewCodeButtons
activeKey=""
handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse}
key="view-code-buttons"
></ViewCodeButtons>,
<Divider
key="divider"
orientation="vertical"
style={{ height: 16, marginInline: 16 }}
/>,
<ExtraContent key="extra-content" />
]}
>
></ViewCodeButtons>
</HeaderRight>
<div className="play-ground">
<div className="chat">
<GroundVideo
@@ -105,7 +97,7 @@ const PlaygroundRerank: React.FC = () => {
></GroundVideo>
</div>
</div>
</PageContainerInner>
</>
);
};