mirror of
https://github.com/labring/sealos.git
synced 2026-09-01 15:38:06 +08:00
feat(desktop): make workspace resource header configurable (#7157)
* feat(desktop): make workspace resource header configurable * fix(desktop): default workspace resource header to balance
This commit is contained in:
@@ -171,7 +171,8 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
||||
|
||||
- 桌面右上角摘要展示当前 workspace 的可用 CPU 以及账号积分;积分复用 `/api/account/getAmount` 的余额净额口径,即 `balance - deductionBalance` 后保留两位小数展示,点击摘要进入费用中心。
|
||||
- 桌面右上角的资源入口通过 `src/pages/api/desktop/getResource.ts` 读取当前登录 workspace namespace 的 Kubernetes `ResourceQuota`,默认 quota 名称为 `quota-${namespace}`。
|
||||
- `/api/desktop/getResource` 保留原有 Pod/PVC 运行资源统计,同时返回 `workspaceQuota`,用于展示 CPU、内存、存储、GPU、NodePort 的总量、已用和可用量。
|
||||
- `/api/desktop/getResource` 保留原有 Pod/PVC 运行资源统计,同时返回 `workspaceQuota`,用于展示 CPU、内存、存储、NodePort 的总量、已用和可用量;GPU 仍在接口里返回,但右上角头部暂不展示。
|
||||
- `desktopConfig.workspaceResourceHeaderEnabled` 控制右上角头部使用资源模式还是旧余额模式,默认关闭,也就是默认显示余额。
|
||||
- `sealos-desktop-sdk` 的 `getWorkspaceQuotaApi` 复用同一个接口,子应用通过 master bridge 获取到的 workspace quota 与桌面右上角展示保持同源。
|
||||
|
||||
### 密码登录初始化契约
|
||||
|
||||
@@ -104,6 +104,7 @@ desktopConfig:
|
||||
enterpriseRealNameAuthEnabled: false # 启用企业实名认证
|
||||
trackingEnabled: false # 启用追踪/统计
|
||||
realNameAuthEnabled: false # 启用实名认证
|
||||
workspaceResourceHeaderEnabled: false # 默认显示余额;设为 true 时使用资源头部
|
||||
licenseCheckEnabled: false # 启用 License 检查
|
||||
passwordEnabled: true # 启用密码登录
|
||||
```
|
||||
|
||||
@@ -104,6 +104,7 @@ desktopConfig:
|
||||
enterpriseRealNameAuthEnabled: false # 启用企业实名认证
|
||||
trackingEnabled: false # 启用追踪/统计
|
||||
realNameAuthEnabled: false # 启用实名认证
|
||||
workspaceResourceHeaderEnabled: false # 默认显示余额;设为 true 时使用资源头部
|
||||
licenseCheckEnabled: false # 启用 License 检查
|
||||
```
|
||||
|
||||
|
||||
@@ -445,6 +445,10 @@ sealos run desktop-frontend:latest \
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.protocol.enabled=false"
|
||||
|
||||
# Enable the top-right workspace resource header
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.workspaceResourceHeaderEnabled=true"
|
||||
|
||||
# OAuth providers via HELM_OPTIONS
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.githubEnabled=true --set desktopConfig.githubClientId=your-client-id --set desktopConfig.githubClientSecret=your-client-secret"
|
||||
@@ -462,7 +466,7 @@ sealos run desktop-frontend:latest \
|
||||
|
||||
**Common customization options:**
|
||||
|
||||
- **UI customization**: `layoutTitle`, `layoutLogo`, `metaTitle`, `metaDescription`, `customerServiceURL`, `protocol.enabled`
|
||||
- **UI customization**: `layoutTitle`, `layoutLogo`, `metaTitle`, `metaDescription`, `customerServiceURL`, `protocol.enabled`, `workspaceResourceHeaderEnabled`
|
||||
- **OAuth providers**: `githubEnabled`, `googleEnabled`, `wechatEnabled`, `oauth2Enabled` and their `*ClientId`, `*ClientSecret`
|
||||
- **Features**: `guideEnabled`, `rechargeEnabled`, `trackingEnabled`, `apiEnabled`, `realNameAuthEnabled`
|
||||
- **Communication**: `smsEnabled`, `emailEnabled`, `emailHost`, `emailPort`, `emailUser`, `emailPassword`
|
||||
|
||||
@@ -445,6 +445,10 @@ sealos run desktop-frontend:latest \
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.protocol.enabled=false"
|
||||
|
||||
# 开启右上角 workspace 资源头部
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.workspaceResourceHeaderEnabled=true"
|
||||
|
||||
# OAuth 提供商
|
||||
sealos run desktop-frontend:latest \
|
||||
-e HELM_OPTIONS="--set desktopConfig.githubEnabled=true --set desktopConfig.githubClientId=your-client-id --set desktopConfig.githubClientSecret=your-client-secret"
|
||||
@@ -462,7 +466,7 @@ sealos run desktop-frontend:latest \
|
||||
|
||||
**常用自定义选项:**
|
||||
|
||||
- **UI 自定义**: `layoutTitle`, `layoutLogo`, `metaTitle`, `metaDescription`, `customerServiceURL`, `protocol.enabled`
|
||||
- **UI 自定义**: `layoutTitle`, `layoutLogo`, `metaTitle`, `metaDescription`, `customerServiceURL`, `protocol.enabled`, `workspaceResourceHeaderEnabled`
|
||||
- **OAuth 提供商**: `githubEnabled`, `googleEnabled`, `wechatEnabled`, `oauth2Enabled` 及其对应的 `*ClientId`, `*ClientSecret`
|
||||
- **功能开关**: `guideEnabled`, `rechargeEnabled`, `trackingEnabled`, `apiEnabled`, `realNameAuthEnabled`
|
||||
- **通讯配置**: `smsEnabled`, `emailEnabled`, `emailHost`, `emailPort`, `emailUser`, `emailPassword`
|
||||
|
||||
@@ -76,6 +76,7 @@ desktopConfig:
|
||||
layoutTitle: "Sealos Cloud"
|
||||
layoutLogo: "/logo.svg"
|
||||
layoutBackgroundImage: "/images/bg-light.svg"
|
||||
workspaceResourceHeaderEnabled: false
|
||||
customerServiceURL: ""
|
||||
layoutDocsUseDocs: false
|
||||
layoutDocsUrl: "https://sealos.run/docs/Intro/"
|
||||
|
||||
@@ -49,6 +49,7 @@ data:
|
||||
title: "{{ .Values.desktopConfig.layoutTitle }}"
|
||||
logo: "{{ .Values.desktopConfig.layoutLogo }}"
|
||||
backgroundImage: "{{ .Values.desktopConfig.layoutBackgroundImage }}"
|
||||
workspaceResourceHeaderEnabled: {{ .Values.desktopConfig.workspaceResourceHeaderEnabled }}
|
||||
forcedLanguage: {{ if eq .Values.desktopConfig.version "cn" }}"zh"{{ else }}"en"{{ end }}
|
||||
customerServiceURL: "{{ .Values.desktopConfig.customerServiceURL }}"
|
||||
discordInviteLink: {{ if eq .Values.desktopConfig.version "en" }}"{{ .Values.desktopConfig.discordInviteLink }}"{{ else }}""{{ end }}
|
||||
|
||||
@@ -96,6 +96,7 @@ desktopConfig:
|
||||
privateProtocol:
|
||||
zh: https://sealos.io/zh-Hans/docs/msa/privacy-policy
|
||||
en: https://sealos.io/docs/msa/privacy-policy
|
||||
workspaceResourceHeaderEnabled: false
|
||||
|
||||
# End of auto-configured values
|
||||
# ============================================================================
|
||||
|
||||
@@ -30,6 +30,9 @@ jest.mock('@/stores/session', () => ({
|
||||
|
||||
jest.mock('@/api/platform', () => ({ getResource: jest.fn() }));
|
||||
jest.mock('@/api/auth', () => ({ getAmount: jest.fn() }));
|
||||
jest.mock('@sealos/ui', () => ({
|
||||
CurrencySymbol: () => null
|
||||
}));
|
||||
|
||||
jest.mock('next-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import React from 'react';
|
||||
import { ChakraProvider, useBreakpointValue } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import SecondaryLinks from '@/components/SecondaryLinks';
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
import { getAmount } from '@/api/auth';
|
||||
import { getResource } from '@/api/platform';
|
||||
import { DefaultLayoutConfig } from '@/types/system';
|
||||
|
||||
jest.mock('@/stores/config', () => ({
|
||||
useConfigStore: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/app', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ openDesktopApp: jest.fn() })
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/guideModal', () => ({
|
||||
useGuideModalStore: () => ({ openGuideModal: jest.fn(), setInitGuide: jest.fn() })
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/session', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
session: {
|
||||
user: {
|
||||
userCrUid: 'user-1',
|
||||
ns_uid: 'ns-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
jest.mock('@/api/platform', () => ({
|
||||
getResource: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('@/api/auth', () => ({
|
||||
getAmount: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('@sealos/ui', () => ({
|
||||
CurrencySymbol: () => null
|
||||
}));
|
||||
|
||||
jest.mock('next-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}));
|
||||
|
||||
jest.mock('@chakra-ui/react', () => {
|
||||
const actual = jest.requireActual('@chakra-ui/react');
|
||||
return {
|
||||
...actual,
|
||||
useBreakpointValue: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseConfigStore = useConfigStore as jest.MockedFunction<typeof useConfigStore>;
|
||||
const mockedUseBreakpointValue = useBreakpointValue as jest.MockedFunction<
|
||||
typeof useBreakpointValue
|
||||
>;
|
||||
const mockedGetResource = getResource as jest.MockedFunction<typeof getResource>;
|
||||
const mockedGetAmount = getAmount as jest.MockedFunction<typeof getAmount>;
|
||||
|
||||
function renderSecondaryLinks(workspaceResourceHeaderEnabled?: boolean) {
|
||||
mockedUseConfigStore.mockReturnValue({
|
||||
layoutConfig: {
|
||||
common: {},
|
||||
currencySymbol: 'shellCoin',
|
||||
...(workspaceResourceHeaderEnabled === undefined ? {} : { workspaceResourceHeaderEnabled })
|
||||
},
|
||||
commonConfig: {
|
||||
guideEnabled: false
|
||||
}
|
||||
} as ReturnType<typeof useConfigStore>);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false }
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
React.createElement(
|
||||
ChakraProvider,
|
||||
null,
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(SecondaryLinks)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe('workspace resource header flag', () => {
|
||||
it('defaults to the balance header', () => {
|
||||
expect(DefaultLayoutConfig.workspaceResourceHeaderEnabled).toBe(false);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedUseBreakpointValue.mockReturnValue(false);
|
||||
mockedGetAmount.mockResolvedValue({
|
||||
data: { balance: 2_000_000, deductionBalance: 500_000 }
|
||||
} as never);
|
||||
mockedGetResource.mockResolvedValue({
|
||||
data: {
|
||||
workspaceQuota: [
|
||||
{ type: 'cpu', used: 1, limit: 2, available: 1, usagePercent: 50 },
|
||||
{ type: 'gpu', used: 1, limit: 1, available: 0, usagePercent: 100 }
|
||||
]
|
||||
}
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('falls back to the balance header when the resource header is disabled', async () => {
|
||||
renderSecondaryLinks(false);
|
||||
|
||||
expect(await screen.findByText('common:balance')).not.toBeNull();
|
||||
expect(screen.queryByText('common:resources')).toBeNull();
|
||||
expect(mockedGetResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the balance header when the flag is omitted', async () => {
|
||||
renderSecondaryLinks();
|
||||
|
||||
expect(await screen.findByText('common:balance')).not.toBeNull();
|
||||
expect(screen.queryByText('common:resources')).toBeNull();
|
||||
expect(mockedGetResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the resource header when enabled', async () => {
|
||||
renderSecondaryLinks(true);
|
||||
|
||||
expect(await screen.findByText('common:resources')).not.toBeNull();
|
||||
expect(screen.queryByText('common:balance')).toBeNull();
|
||||
await waitFor(() => expect(mockedGetResource).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { getResource } from '@/api/platform';
|
||||
import Decimal from 'decimal.js';
|
||||
import { getAmount } from '@/api/auth';
|
||||
import { getResource } from '@/api/platform';
|
||||
import { CurrencySymbol } from '@sealos/ui';
|
||||
import useSessionStore from '@/stores/session';
|
||||
import { formatMoney } from '@/utils/format';
|
||||
import { Activity, MoreHorizontal } from 'lucide-react';
|
||||
@@ -49,13 +51,7 @@ type WorkspaceQuotaItem = {
|
||||
usagePercent: number;
|
||||
};
|
||||
|
||||
const resourceDisplayOrder: WorkspaceQuotaItem['type'][] = [
|
||||
'cpu',
|
||||
'memory',
|
||||
'storage',
|
||||
'gpu',
|
||||
'nodeport'
|
||||
];
|
||||
const resourceDisplayOrder: WorkspaceQuotaItem['type'][] = ['cpu', 'memory', 'storage', 'nodeport'];
|
||||
|
||||
const getAvailableValue = (item: WorkspaceQuotaItem) =>
|
||||
item.available ?? Math.max(item.limit - item.used, 0);
|
||||
@@ -74,6 +70,8 @@ export default function SecondaryLinks() {
|
||||
const { session } = useSessionStore();
|
||||
|
||||
const user = session?.user;
|
||||
const workspaceResourceHeaderEnabled = layoutConfig?.workspaceResourceHeaderEnabled === true;
|
||||
const currencySymbol = layoutConfig?.currencySymbol || 'shellCoin';
|
||||
|
||||
const isCollapsed = useBreakpointValue({
|
||||
base: true,
|
||||
@@ -97,17 +95,25 @@ export default function SecondaryLinks() {
|
||||
const { data: resourceData, isLoading: isResourceLoading } = useQuery({
|
||||
queryKey: ['getResource', { userId: user?.userCrUid, workspaceId: user?.ns_uid }],
|
||||
queryFn: getResource,
|
||||
enabled: !!user,
|
||||
enabled: !!user && workspaceResourceHeaderEnabled,
|
||||
staleTime: 60 * 1000
|
||||
});
|
||||
|
||||
const { data: amountData, isLoading: isAmountLoading } = useQuery({
|
||||
const { data: amountData } = useQuery({
|
||||
queryKey: ['getAmount', { userId: user?.userCrUid }],
|
||||
queryFn: getAmount,
|
||||
enabled: !!user,
|
||||
staleTime: 60 * 1000
|
||||
});
|
||||
|
||||
const balance = useMemo(() => {
|
||||
let realBalance = new Decimal(amountData?.data?.balance || 0);
|
||||
if (amountData?.data?.deductionBalance) {
|
||||
realBalance = realBalance.minus(new Decimal(amountData.data.deductionBalance));
|
||||
}
|
||||
return realBalance.toNumber();
|
||||
}, [amountData]);
|
||||
|
||||
const quotaItems = useMemo(() => {
|
||||
const items = resourceData?.data?.workspaceQuota || [];
|
||||
return resourceDisplayOrder.reduce<WorkspaceQuotaItem[]>((acc, type) => {
|
||||
@@ -133,13 +139,7 @@ export default function SecondaryLinks() {
|
||||
: isResourceLoading
|
||||
? t('common:loading')
|
||||
: '--';
|
||||
|
||||
const creditsValue = amountData?.data
|
||||
? formatMoney((amountData.data.balance || 0) - (amountData.data.deductionBalance || 0))
|
||||
: null;
|
||||
|
||||
const creditsSummary =
|
||||
creditsValue !== null ? creditsValue.toFixed(2) : isAmountLoading ? t('common:loading') : '--';
|
||||
const balanceSummary = formatMoney(balance).toFixed(2);
|
||||
|
||||
const renderHeaderMetric = (label: string, value: string) => (
|
||||
<Flex alignItems="baseline" gap="4px" whiteSpace="nowrap" minW={0}>
|
||||
@@ -156,14 +156,10 @@ export default function SecondaryLinks() {
|
||||
<Flex alignItems="center" gap="8px" minW={0} flexWrap={allowWrap ? 'wrap' : 'nowrap'}>
|
||||
{renderHeaderMetric(t('common:resources'), resourceSummary)}
|
||||
<Divider orientation="vertical" h="16px" borderColor="rgba(37, 99, 235, 0.14)" />
|
||||
{renderHeaderMetric(t('common:credits'), creditsSummary)}
|
||||
{renderHeaderMetric(t('common:credits'), balanceSummary)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
openCostCenterApp();
|
||||
};
|
||||
|
||||
const getQuotaLabel = (type: WorkspaceQuotaItem['type']) => {
|
||||
if (type === 'cpu') return 'CPU';
|
||||
if (type === 'memory') return t('common:memory');
|
||||
@@ -239,102 +235,241 @@ export default function SecondaryLinks() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCollapsed) {
|
||||
return (
|
||||
<Flex gap={'4px'} ml={'auto'}>
|
||||
<Popover trigger="hover" placement="bottom-end" openDelay={120}>
|
||||
<PopoverTrigger>
|
||||
<Center
|
||||
mr={'12px'}
|
||||
borderRadius={'8px'}
|
||||
bg={
|
||||
'linear-gradient(90deg, rgba(129, 203, 252, 0.12) 0%, rgba(81, 159, 245, 0.12) 100%)'
|
||||
}
|
||||
h={'36px'}
|
||||
minW="252px"
|
||||
maxW="320px"
|
||||
px={'12px'}
|
||||
py={'8px'}
|
||||
color="#2563EB"
|
||||
fontSize={'14px'}
|
||||
fontWeight={'500'}
|
||||
cursor={'pointer'}
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<Activity size={16} />
|
||||
<Box ml="8px">{renderHeaderSummary()}</Box>
|
||||
</Center>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="320px"
|
||||
borderRadius="8px"
|
||||
boxShadow="0 12px 32px rgba(15, 23, 42, 0.16)"
|
||||
>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="14px">{renderResourceRows()}</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
const handleHeaderClick = () => {
|
||||
openCostCenterApp();
|
||||
};
|
||||
|
||||
{commonConfig?.guideEnabled ? (
|
||||
<Center
|
||||
className="guide-button"
|
||||
cursor={'pointer'}
|
||||
{...baseItemStyle}
|
||||
px={'8px'}
|
||||
borderRadius={'8px'}
|
||||
onClick={handleGuideClick}
|
||||
>
|
||||
{t('common:guide')}
|
||||
</Center>
|
||||
) : null}
|
||||
const renderDesktopActions = () => (
|
||||
<>
|
||||
{commonConfig?.guideEnabled ? (
|
||||
<Center
|
||||
className="guide-button"
|
||||
cursor={'pointer'}
|
||||
{...baseItemStyle}
|
||||
px={'8px'}
|
||||
borderRadius={'8px'}
|
||||
onClick={handleGuideClick}
|
||||
>
|
||||
{t('common:guide')}
|
||||
</Center>
|
||||
) : null}
|
||||
|
||||
{layoutConfig?.common.docsUrl && (
|
||||
<Center
|
||||
{...baseItemStyle}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'8px'}
|
||||
px={'8px'}
|
||||
onClick={handleDocsClick}
|
||||
>
|
||||
{t('common:doc')}
|
||||
</Center>
|
||||
)}
|
||||
{layoutConfig?.common.docsUrl && (
|
||||
<Center
|
||||
{...baseItemStyle}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'8px'}
|
||||
px={'8px'}
|
||||
onClick={handleDocsClick}
|
||||
>
|
||||
{t('common:doc')}
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{layoutConfig?.common.workorderEnabled && (
|
||||
{layoutConfig?.common.workorderEnabled && (
|
||||
<Center
|
||||
cursor={'pointer'}
|
||||
{...baseItemStyle}
|
||||
px={'8px'}
|
||||
borderRadius={'8px'}
|
||||
border={'1px solid transparent'}
|
||||
onClick={openWorkOrderApp}
|
||||
>
|
||||
{t('v2:ticket')}
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{layoutConfig?.discordInviteLink && (
|
||||
<JoinDiscordPrompt>
|
||||
<Center
|
||||
cursor={'pointer'}
|
||||
{...baseItemStyle}
|
||||
minW="36px"
|
||||
h="40px"
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
color="primary"
|
||||
_hover={{
|
||||
background: 'secondary'
|
||||
}}
|
||||
px={'8px'}
|
||||
borderRadius={'8px'}
|
||||
border={'1px solid transparent'}
|
||||
onClick={openWorkOrderApp}
|
||||
>
|
||||
{t('v2:ticket')}
|
||||
{t('v2:support')}
|
||||
</Center>
|
||||
)}
|
||||
</JoinDiscordPrompt>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{layoutConfig?.discordInviteLink && (
|
||||
<JoinDiscordPrompt>
|
||||
<Center
|
||||
cursor={'pointer'}
|
||||
minW="36px"
|
||||
h="40px"
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
color="primary"
|
||||
_hover={{
|
||||
background: 'secondary'
|
||||
}}
|
||||
px={'8px'}
|
||||
borderRadius={'8px'}
|
||||
border={'1px solid transparent'}
|
||||
const renderMenuActions = () => (
|
||||
<>
|
||||
<Divider my="8px" mx="-8px" w="calc(100% + 16px)" bg="rgba(0, 0, 0, 0.1)" />
|
||||
|
||||
{layoutConfig?.common.docsUrl && (
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={handleDocsClick}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('common:doc')}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{layoutConfig?.common.workorderEnabled && (
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={openWorkOrderApp}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('v2:ticket')}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{layoutConfig?.discordInviteLink && (
|
||||
<JoinDiscordPrompt>
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={openWorkOrderApp}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('v2:support')}
|
||||
</MenuItem>
|
||||
</JoinDiscordPrompt>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (workspaceResourceHeaderEnabled) {
|
||||
if (!isCollapsed) {
|
||||
return (
|
||||
<Flex gap={'4px'} ml={'auto'}>
|
||||
<Popover trigger="hover" placement="bottom-end" openDelay={120}>
|
||||
<PopoverTrigger>
|
||||
<Center
|
||||
mr={'12px'}
|
||||
borderRadius={'8px'}
|
||||
bg={
|
||||
'linear-gradient(90deg, rgba(129, 203, 252, 0.12) 0%, rgba(81, 159, 245, 0.12) 100%)'
|
||||
}
|
||||
h={'36px'}
|
||||
minW="252px"
|
||||
maxW="320px"
|
||||
px={'12px'}
|
||||
py={'8px'}
|
||||
color="#2563EB"
|
||||
fontSize={'14px'}
|
||||
fontWeight={'500'}
|
||||
cursor={'pointer'}
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<Activity size={16} />
|
||||
<Box ml="8px">{renderHeaderSummary()}</Box>
|
||||
</Center>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="320px"
|
||||
borderRadius="8px"
|
||||
boxShadow="0 12px 32px rgba(15, 23, 42, 0.16)"
|
||||
>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p="14px">{renderResourceRows()}</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{renderDesktopActions()}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Center}
|
||||
{...baseItemStyle}
|
||||
tabIndex={0}
|
||||
boxSize={'36px'}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'full'}
|
||||
border={'1px solid rgba(0, 0, 0, 0.05)'}
|
||||
px={'8px'}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
p="8px"
|
||||
borderRadius="12px"
|
||||
boxShadow="0 4px 20px rgba(0, 0, 0, 0.15)"
|
||||
border="1px solid rgba(0, 0, 0, 0.05)"
|
||||
minW="200px"
|
||||
>
|
||||
<Box
|
||||
borderRadius="8px"
|
||||
bg={
|
||||
'linear-gradient(90deg, rgba(129, 203, 252, 0.12) 0%, rgba(81, 159, 245, 0.12) 100%)'
|
||||
}
|
||||
p="12px"
|
||||
cursor="pointer"
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap="10px"
|
||||
color="#2563EB"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{t('v2:support')}
|
||||
</Center>
|
||||
</JoinDiscordPrompt>
|
||||
)}
|
||||
<Flex alignItems="center" gap="6px" fontSize="14px" fontWeight={600}>
|
||||
<Activity size={16} />
|
||||
</Flex>
|
||||
{renderHeaderSummary({ allowWrap: true })}
|
||||
</Flex>
|
||||
<Box mt="12px">{renderResourceRows({ showTitle: false })}</Box>
|
||||
</Box>
|
||||
|
||||
{renderMenuActions()}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCollapsed) {
|
||||
return (
|
||||
<Flex gap={'4px'} ml={'auto'}>
|
||||
<Center
|
||||
mr={'12px'}
|
||||
borderRadius={'8px'}
|
||||
bg={'linear-gradient(90deg, rgba(129, 203, 252, 0.12) 0%, rgba(81, 159, 245, 0.12) 100%)'}
|
||||
h={'36px'}
|
||||
px={'12px'}
|
||||
py={'8px'}
|
||||
color="#2563EB"
|
||||
fontSize={'14px'}
|
||||
fontWeight={'500'}
|
||||
cursor={'pointer'}
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<Text>{t('common:balance')}</Text>
|
||||
<Divider orientation="vertical" mx={'12px'} />
|
||||
<CurrencySymbol type={currencySymbol} />
|
||||
<Text ml={'4px'}>{balanceSummary}</Text>
|
||||
</Center>
|
||||
|
||||
{renderDesktopActions()}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -377,69 +512,32 @@ export default function SecondaryLinks() {
|
||||
<Flex alignItems="center" gap="6px" fontSize="14px" fontWeight={600}>
|
||||
<Activity size={16} />
|
||||
</Flex>
|
||||
{renderHeaderSummary({ allowWrap: true })}
|
||||
<Flex alignItems="center" gap="4px" whiteSpace="nowrap" minW={0}>
|
||||
<Text
|
||||
fontSize="13px"
|
||||
fontWeight={500}
|
||||
lineHeight="20px"
|
||||
color="rgba(45, 65, 91, 0.68)"
|
||||
>
|
||||
{t('common:balance')}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight={600}
|
||||
lineHeight="20px"
|
||||
color="primary"
|
||||
noOfLines={1}
|
||||
>
|
||||
<CurrencySymbol type={currencySymbol} />
|
||||
<Text as="span" ml="4px">
|
||||
{balanceSummary}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box mt="12px">{renderResourceRows({ showTitle: false })}</Box>
|
||||
</Box>
|
||||
|
||||
<Divider my="8px" mx="-8px" w="calc(100% + 16px)" bg="rgba(0, 0, 0, 0.1)" />
|
||||
|
||||
{/* // [TODO] Guide is currently not compatible with narrow screen. */}
|
||||
{/* <MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={handleGuideClick}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('common:guide')}
|
||||
</MenuItem> */}
|
||||
|
||||
{layoutConfig?.common.docsUrl && (
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={handleDocsClick}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('common:doc')}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{layoutConfig?.common.workorderEnabled && (
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={openWorkOrderApp}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('v2:ticket')}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{layoutConfig?.discordInviteLink && (
|
||||
<JoinDiscordPrompt>
|
||||
<MenuItem
|
||||
py="8px"
|
||||
px="12px"
|
||||
borderRadius="8px"
|
||||
_hover={{ bg: 'rgba(0, 0, 0, 0.05)' }}
|
||||
onClick={openWorkOrderApp}
|
||||
fontSize="14px"
|
||||
fontWeight="500"
|
||||
>
|
||||
{t('v2:support')}
|
||||
</MenuItem>
|
||||
</JoinDiscordPrompt>
|
||||
)}
|
||||
{renderMenuActions()}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ export type LayoutConfigType = {
|
||||
title: string;
|
||||
logo: string;
|
||||
backgroundImage: string;
|
||||
workspaceResourceHeaderEnabled?: boolean;
|
||||
authTitle?: {
|
||||
zh: string;
|
||||
en: string;
|
||||
@@ -306,6 +307,7 @@ export const DefaultLayoutConfig: LayoutConfigType = {
|
||||
title: 'Sealos Cloud',
|
||||
logo: '/logo.svg',
|
||||
backgroundImage: '/images/bg-light.svg',
|
||||
workspaceResourceHeaderEnabled: false,
|
||||
protocol: {
|
||||
enabled: true,
|
||||
serviceProtocol: {
|
||||
|
||||
Reference in New Issue
Block a user