Compare commits

...
2 Commits
Author SHA1 Message Date
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> a90e02a04c feat(layout): move navigation menu from sidebar to top header
Switch ProLayout to top layout with a custom horizontal HeaderMenu
(dropdowns for grouped items) and relocate the user/help/version actions
into the header right area.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-02 09:17:48 +08:00
lofyerandClaude Opus 4.8 9a223f9565 feat(branding): rebrand footer vendor to MesaStack, trim footer/topbar
- Footer vendor label unified to "MesaStack" across all locales.
- Remove footer Help button; keep version as plain text (no click).
- Remove top-right GitHub star widget (delete unused github-star.tsx);
  keep version as plain text (no click), retain upgrade "New" badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:35:39 +08:00
11 changed files with 161 additions and 260 deletions
+2 -25
View File
@@ -1,9 +1,7 @@
import { GPUStackVersionAtom } from '@/atoms/user'; import { GPUStackVersionAtom } from '@/atoms/user';
import { getAtomStorage } from '@/atoms/utils'; import { getAtomStorage } from '@/atoms/utils';
import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Divider, Modal, Typography } from 'antd'; import { Divider, Typography } from 'antd';
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -33,20 +31,10 @@ const useStyles = createStyles(({ token, css }) => ({
const Footer: React.FC = () => { const Footer: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [modal, contextHolder] = Modal.useModal();
const { styles } = useStyles(); const { styles } = useStyles();
const showVersion = () => {
modal.info({
...modalConfig,
width: 460,
content: <VersionInfo intl={intl} />
});
};
return ( return (
<> <>
{contextHolder}
<div className={styles.footer}> <div className={styles.footer}>
<div className="footer-content"> <div className="footer-content">
<div className="footer-content-left"> <div className="footer-content-left">
@@ -63,18 +51,7 @@ const Footer: React.FC = () => {
</Typography.Link> </Typography.Link>
</CompanyWrapper> </CompanyWrapper>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Button <span>{getAtomStorage(GPUStackVersionAtom)?.version}</span>
type="link"
size="small"
href={externalLinks.documentation}
target="_blank"
>
{intl.formatMessage({ id: 'common.button.help' })}
</Button>
<Divider orientation="vertical" />
<Button type="link" size="small" onClick={showVersion}>
{getAtomStorage(GPUStackVersionAtom)?.version}
</Button>
</div> </div>
</div> </div>
</div> </div>
+4 -24
View File
@@ -1,8 +1,6 @@
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user'; import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import PluginExtraField from '@/components/plugin-extra-fields'; import PluginExtraField from '@/components/plugin-extra-fields';
import VersionInfo, { modalConfig } from '@/components/version-info';
import externalLinks from '@/constants/external-links'; import externalLinks from '@/constants/external-links';
import useBodyScroll from '@/hooks/use-body-scroll';
import { logout } from '@/pages/login/apis'; import { logout } from '@/pages/login/apis';
import { useModel } from '@@/plugin-model'; import { useModel } from '@@/plugin-model';
import { import {
@@ -13,12 +11,11 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { DropdownActions, IconFont } from '@gpustack/core-ui'; import { DropdownActions, IconFont } from '@gpustack/core-ui';
import { history, useIntl, useNavigate } from '@umijs/max'; import { history, useIntl, useNavigate } from '@umijs/max';
import { Avatar, Button, Divider, Modal } from 'antd'; import { Avatar, Divider } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DEFAULT_ENTER_PAGE } from '../config/settings'; import { DEFAULT_ENTER_PAGE } from '../config/settings';
import GithubStar from './github-star';
const NewLabel = styled.span` const NewLabel = styled.span`
position: relative; position: relative;
@@ -98,11 +95,9 @@ const CustomItem = styled.div`
export const ExtraContent = (props: { isDarkTheme?: boolean }) => { export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
const { isDarkTheme } = props; const { isDarkTheme } = props;
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll(); const intl = useIntl();
const [modal, contextHolder] = Modal.useModal();
const [version] = useAtom(GPUStackVersionAtom); const [version] = useAtom(GPUStackVersionAtom);
const [updateCheck] = useAtom(UpdateCheckAtom); const [updateCheck] = useAtom(UpdateCheckAtom);
const intl = useIntl();
const initialInfo = useModel('@@initialState') || { const initialInfo = useModel('@@initialState') || {
initialState: undefined, initialState: undefined,
loading: false, loading: false,
@@ -140,16 +135,6 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
return {}; return {};
}, [isDarkTheme]); }, [isDarkTheme]);
const showVersion = () => {
saveScrollHeight();
modal.info({
...modalConfig,
width: 460,
content: <VersionInfo intl={intl} />,
onCancel: restoreScrollHeight
});
};
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate(loginPath); navigate(loginPath);
@@ -259,25 +244,20 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
return ( return (
<Wrapper> <Wrapper>
{contextHolder}
<PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} /> <PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} />
{process.env.ENABLE_ENTERPRISE !== 'true' && <GithubStar />}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center' alignItems: 'center'
}} }}
> >
<Button <span
type="text"
size="small"
onClick={showVersion}
style={{ style={{
color: 'var(--ant-color-text-tertiary)' color: 'var(--ant-color-text-tertiary)'
}} }}
> >
{version.version} {version.version}
</Button> </span>
{showUpgrade && ( {showUpgrade && (
<NewLabel> <NewLabel>
<span className="text"> <span className="text">
-140
View File
@@ -1,140 +0,0 @@
import externalLinks from '@/constants/external-links';
import { GithubFilled } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import styled from 'styled-components';
const REPO = 'gpustack/gpustack';
const CACHE_KEY = 'gpustack:github-stars';
const CACHE_TTL = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT = 4000;
const StarLink = styled.a`
display: inline-flex;
align-items: stretch;
height: 24px;
border-radius: var(--ant-border-radius);
border: 1px solid var(--ant-color-border-secondary);
background-color: var(--ant-color-bg-container);
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1;
overflow: hidden;
transition:
border-color 0.2s,
color 0.2s;
&:hover {
border-color: var(--ant-color-border);
color: var(--ant-color-text);
}
.seg {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
}
.seg + .seg {
border-left: 1px solid var(--ant-color-border-secondary);
background-color: var(--ant-color-fill-quaternary);
}
.anticon {
font-size: 13px;
}
.count {
font-weight: 500;
font-variant-numeric: tabular-nums;
min-width: 1.5em;
text-align: center;
}
`;
const formatCount = (n: number): string => {
if (n >= 1000) {
const k = n / 1000;
return k >= 10 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
}
return String(n);
};
type CacheEntry = { value: number; time: number };
const readCache = (): CacheEntry | null => {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (typeof parsed?.value !== 'number' || typeof parsed?.time !== 'number') {
return null;
}
return parsed;
} catch {
return null;
}
};
const writeCache = (value: number) => {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ value, time: Date.now() })
);
} catch {
// ignore quota errors
}
};
const GithubStar = () => {
const intl = useIntl();
const [count, setCount] = useState<number | null>(
() => readCache()?.value ?? null
);
useEffect(() => {
const cached = readCache();
const fresh = cached && Date.now() - cached.time < CACHE_TTL;
if (fresh) return;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
fetch(`https://api.github.com/repos/${REPO}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!data || typeof data.stargazers_count !== 'number') return;
setCount(data.stargazers_count);
writeCache(data.stargazers_count);
})
.catch(() => {
// offline, blocked, rate-limited — stay hidden if no cache
})
.finally(() => clearTimeout(timer));
return () => {
clearTimeout(timer);
controller.abort();
};
}, []);
return (
<Tooltip title={intl.formatMessage({ id: 'common.github.star.tooltip' })}>
<StarLink href={externalLinks.github} target="_blank" rel="noreferrer">
<span className="seg">
<GithubFilled />
</span>
<span className="seg">
<span className="count">
{count != null ? formatCount(count) : 'Star'}
</span>
</span>
</StarLink>
</Tooltip>
);
};
export default GithubStar;
+128
View File
@@ -0,0 +1,128 @@
import { IconFont } from '@gpustack/core-ui';
import { Link, useLocation, useNavigate } from '@umijs/max';
import { Menu } from 'antd';
import { createStyles } from 'antd-style';
import React, { useMemo } from 'react';
interface MenuItem {
icon?: string;
selectedIcon?: string;
defaultIcon?: string;
children?: MenuItem[];
[key: string]: any;
}
interface HeaderMenuProps {
menuData: MenuItem[];
initialState?: Global.InitialStateType;
}
const useStyles = createStyles(({ css }) => {
return {
headerMenu: css`
flex: 1;
min-width: 0;
background: transparent;
border-bottom: none;
line-height: inherit;
&.ant-menu-horizontal {
border-bottom: none;
}
&.ant-menu-horizontal > .ant-menu-item::after,
&.ant-menu-horizontal > .ant-menu-submenu::after {
display: none;
}
.ant-menu-title-content {
display: inline-flex;
align-items: center;
gap: 8px;
}
.anticon {
font-size: 16px;
}
`
};
});
const isItemSelected = (item: MenuItem, pathname: string) => {
return (
pathname === item.path ||
(Array.isArray(item.subMenu) && item.subMenu.includes(pathname))
);
};
const HeaderMenu: React.FC<HeaderMenuProps> = (props) => {
const { menuData } = props;
const { styles } = useStyles();
const location = useLocation();
const navigate = useNavigate();
const buildLeaf = (item: MenuItem) => {
const selected = isItemSelected(item, location.pathname);
return {
key: item.path as string,
label: (
<Link
prefetch="intent"
to={(item.path as string).replace('/*', '')}
target={item.target}
>
<span className="flex-center gap-8">
<IconFont
type={selected ? item.selectedIcon || '' : item.defaultIcon || ''}
/>
<span>{item.name}</span>
</span>
</Link>
)
};
};
const items = useMemo(() => {
return menuData.map((item) => {
if (item.children && item.children.length > 0) {
return {
key: item.key,
label: item.name,
children: item.children.map((child) => buildLeaf(child))
};
}
return buildLeaf(item);
});
}, [menuData, location.pathname]);
const selectedKeys = useMemo(() => {
const keys: string[] = [];
for (const item of menuData) {
const leaves =
item.children && item.children.length > 0 ? item.children : [item];
for (const leaf of leaves) {
if (isItemSelected(leaf, location.pathname)) {
keys.push(leaf.path as string);
}
}
}
return keys;
}, [menuData, location.pathname]);
const handleClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
navigate(key.replace('/*', ''));
}
};
return (
<Menu
className={styles.headerMenu}
mode="horizontal"
selectedKeys={selectedKeys}
items={items}
onClick={handleClick}
triggerSubMenuAction="hover"
/>
);
};
export default HeaderMenu;
+21 -57
View File
@@ -22,7 +22,7 @@ import {
import { useAccessMarkedRoutes } from '@@/plugin-access'; import { useAccessMarkedRoutes } from '@@/plugin-access';
import { useModel } from '@@/plugin-model'; import { useModel } from '@@/plugin-model';
import { ProLayout } from '@ant-design/pro-components'; import { ProLayout } from '@ant-design/pro-components';
import { CoreUIProvider, IconFont } from '@gpustack/core-ui'; import { CoreUIProvider } from '@gpustack/core-ui';
import { import {
Access, Access,
Outlet, Outlet,
@@ -39,18 +39,18 @@ import {
useNavigate, useNavigate,
type IRoute type IRoute
} from '@umijs/max'; } from '@umijs/max';
import { Button, ConfigProvider, Modal, theme } from 'antd'; import { ConfigProvider, Modal, theme } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import 'overlayscrollbars/overlayscrollbars.css'; import 'overlayscrollbars/overlayscrollbars.css';
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { PageContainerInner } from '../pages/_components/page-box'; import { PageContainerInner } from '../pages/_components/page-box';
import Exception from './Exception'; import Exception from './Exception';
import './Layout.css'; import './Layout.css';
import { LogoIcon, SLogoIcon } from './Logo'; import { LogoIcon } from './Logo';
import ErrorBoundary from './error-boundary'; import ErrorBoundary from './error-boundary';
import { ExtraContent } from './extraRender'; import { ExtraContent } from './extraRender';
import HeaderMenu from './header-menu';
import { patchRoutes } from './runtime'; import { patchRoutes } from './runtime';
import SiderMenu from './sider-menu';
// Pages that use the page container in the page // Pages that use the page container in the page
const NO_CONTAINER_PAGES = [ const NO_CONTAINER_PAGES = [
@@ -133,7 +133,7 @@ const mapRoutes = (routes: IRoute[], role: string) => {
export default (props: any) => { export default (props: any) => {
const [, contextHolder] = Modal.useModal(); const [, contextHolder] = Modal.useModal();
const { themeData, setUserSettings, userSettings } = useUserSettings(); const { themeData, userSettings } = useUserSettings();
const [userInfo] = useAtom(userAtom); const [userInfo] = useAtom(userAtom);
const [routeCache] = useAtom(routeCacheAtom); const [routeCache] = useAtom(routeCacheAtom);
const location = useLocation(); const location = useLocation();
@@ -238,13 +238,6 @@ export default (props: any) => {
const coreUISlots = useMemo(() => ({ ExtraContent }), []); const coreUISlots = useMemo(() => ({ ExtraContent }), []);
const handleToggleCollapse = (e: any) => {
e.stopPropagation();
setUserSettings({
...userSettings,
collapsed: !userSettings.collapsed
});
};
const newRoutes = filterRoutes( const newRoutes = filterRoutes(
// @ts-ignore // @ts-ignore
clientRoutes.filter((route) => route.id === 'max-tabs'), clientRoutes.filter((route) => route.id === 'max-tabs'),
@@ -274,16 +267,19 @@ export default (props: any) => {
return NO_CONTAINER_PAGES.includes(matchedRoute?.name as string); return NO_CONTAINER_PAGES.includes(matchedRoute?.name as string);
}, [matchedRoute]); }, [matchedRoute]);
const collapsed = useMemo(() => {
return userSettings.collapsed || false;
}, [userSettings.collapsed]);
const renderMenuHeader = (logo: React.ReactNode, title: React.ReactNode) => { const renderMenuHeader = (logo: React.ReactNode, title: React.ReactNode) => {
return <>{logo}</>; return <>{logo}</>;
}; };
const menuContentRender = (menuProps: any, defaultDom: React.ReactNode) => { const headerContentRender = (
return <SiderMenu {...menuProps}></SiderMenu>; headerProps: any,
defaultDom: React.ReactNode
) => {
return <HeaderMenu {...headerProps}></HeaderMenu>;
};
const actionsRender = () => {
return <ExtraContent isDarkTheme={userSettings.isDarkTheme} />;
}; };
const onPageChange = async (route: any) => { const onPageChange = async (route: any) => {
@@ -337,17 +333,6 @@ export default (props: any) => {
navigate(pagepath); navigate(pagepath);
}; };
const onCollapse = (value: boolean) => {
// only trigger by window resize
if (!value) {
return;
}
setUserSettings({
...userSettings,
collapsed: value
});
};
return ( return (
<ConfigProvider <ConfigProvider
componentSize="large" componentSize="large"
@@ -408,48 +393,27 @@ export default (props: any) => {
<DarkMask></DarkMask> <DarkMask></DarkMask>
<ProLayout <ProLayout
fixSiderbar fixSiderbar
fixedHeader={false} fixedHeader
headerRender={false}
breadcrumbRender={false} breadcrumbRender={false}
route={route} route={route}
location={location} location={location}
title={userConfig.title} title={userConfig.title}
navTheme={userSettings.theme} navTheme={userSettings.theme}
layout="side" layout="top"
contentStyle={{ contentStyle={{
paddingBlock: 0, paddingBlock: 0,
paddingInline: 0 paddingInline: 0
}} }}
openKeys={false}
disableMobile={true} disableMobile={true}
siderWidth={220}
menuFooterRender={() => (
<Button
style={{
border: 'none'
}}
size="small"
type={'text'}
onClick={handleToggleCollapse}
>
<IconFont
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
className="font-size-18"
/>
</Button>
)}
onCollapse={onCollapse}
onMenuHeaderClick={onMenuHeaderClick} onMenuHeaderClick={onMenuHeaderClick}
collapsed={userSettings.collapsed}
onPageChange={onPageChange} onPageChange={onPageChange}
formatMessage={formatMessage} formatMessage={formatMessage}
menu={{ menu={{
locale: true, locale: true
type: 'group'
}} }}
splitMenus={true} logo={<LogoIcon />}
logo={userSettings.collapsed ? <SLogoIcon /> : <LogoIcon />} headerContentRender={headerContentRender}
menuContentRender={menuContentRender} actionsRender={actionsRender}
{...runtimeConfig} {...runtimeConfig}
ErrorBoundary={ErrorBoundary} ErrorBoundary={ErrorBoundary}
> >
@@ -457,7 +421,7 @@ export default (props: any) => {
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
height: '100vh', height: '100%',
overflow: 'hidden' overflow: 'hidden'
}} }}
> >
+1 -1
View File
@@ -214,7 +214,7 @@ export default {
'common.form.password': 'Password', 'common.form.password': 'Password',
'common.form.username': 'Username', 'common.form.username': 'Username',
'common.login.rember': 'Remember me', 'common.login.rember': 'Remember me',
'settings.company': 'MesaStack.ai', 'settings.company': 'MesaStack',
'common.button.help': 'Help', 'common.button.help': 'Help',
'common.button.feedback': 'Feedback', 'common.button.feedback': 'Feedback',
'common.button.docs': 'Documentation', 'common.button.docs': 'Documentation',
+1 -1
View File
@@ -213,7 +213,7 @@ export default {
'common.form.password': 'パスワード', 'common.form.password': 'パスワード',
'common.form.username': 'ユーザー名', 'common.form.username': 'ユーザー名',
'common.login.rember': 'ログイン状態を保持', 'common.login.rember': 'ログイン状態を保持',
'settings.company': 'MesaStack.ai', 'settings.company': 'MesaStack',
'common.button.help': 'ヘルプ', 'common.button.help': 'ヘルプ',
'common.button.feedback': 'フィードバック', 'common.button.feedback': 'フィードバック',
'common.button.docs': 'ドキュメント', 'common.button.docs': 'ドキュメント',
+1 -1
View File
@@ -211,7 +211,7 @@ export default {
'common.form.password': 'Пароль', 'common.form.password': 'Пароль',
'common.form.username': 'Имя пользователя', 'common.form.username': 'Имя пользователя',
'common.login.rember': 'Запомнить меня', 'common.login.rember': 'Запомнить меня',
'settings.company': 'MesaStack.ai', 'settings.company': 'MesaStack',
'common.button.help': 'Помощь', 'common.button.help': 'Помощь',
'common.button.feedback': 'Обратная связь', 'common.button.feedback': 'Обратная связь',
'common.button.docs': 'Документация', 'common.button.docs': 'Документация',
+1 -1
View File
@@ -216,7 +216,7 @@ export default {
'common.form.password': 'Şifre', 'common.form.password': 'Şifre',
'common.form.username': 'Kullanıcı adı', 'common.form.username': 'Kullanıcı adı',
'common.login.rember': 'Beni hatırla', 'common.login.rember': 'Beni hatırla',
'settings.company': 'MesaStack.ai', 'settings.company': 'MesaStack',
'common.button.help': 'Yardım', 'common.button.help': 'Yardım',
'common.button.feedback': 'Geri Bildirim', 'common.button.feedback': 'Geri Bildirim',
'common.button.docs': 'Dokümantasyon', 'common.button.docs': 'Dokümantasyon',
+1 -1
View File
@@ -204,7 +204,7 @@ export default {
'common.form.password': '密码', 'common.form.password': '密码',
'common.form.username': '用户名', 'common.form.username': '用户名',
'common.login.rember': '记住我', 'common.login.rember': '记住我',
'settings.company': '数澈软件', 'settings.company': 'MesaStack',
'common.button.help': '帮助', 'common.button.help': '帮助',
'common.button.feedback': '反馈', 'common.button.feedback': '反馈',
'common.button.docs': '文档', 'common.button.docs': '文档',
+1 -9
View File
@@ -1,11 +1,9 @@
import { ExtraContent } from '@/layouts/extraRender';
import { import {
PageContainer, PageContainer,
RouteContext, RouteContext,
type PageContainerProps type PageContainerProps
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { useOverlayScroller } from '@gpustack/core-ui'; import { useOverlayScroller } from '@gpustack/core-ui';
import { Divider } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { useContext, useEffect, useRef } from 'react'; import { useContext, useEffect, useRef } from 'react';
import pageBoxCss from './styles/page-box.less'; import pageBoxCss from './styles/page-box.less';
@@ -53,13 +51,7 @@ export const PageContainerInner: React.FC<
{leftContent || pageContext.title} {leftContent || pageContext.title}
</div> </div>
<div className={pageBoxCss.right}> <div className={pageBoxCss.right}>
{rightContent && ( {rightContent && <div>{rightContent}</div>}
<div>
{rightContent}
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
</div>
)}
<ExtraContent />
</div> </div>
</div> </div>
<div <div