refactor: adjusted license billing based on cluster size (#4853)

* refactor: adjusted license billing based on cluster size

* One-click purchase

* add active cluster

* license app add cronjob

Signed-off-by: jingyang <3161362058@qq.com>

* fix default time

* add  Asia/Shanghai

* Delete Old Notifications

* fix ci

---------

Signed-off-by: jingyang <3161362058@qq.com>
This commit is contained in:
jingyang
2024-07-08 18:59:19 +08:00
committed by GitHub
parent cc141d5c3d
commit 2176fbb8b4
65 changed files with 2035 additions and 1922 deletions
+3
View File
@@ -1695,6 +1695,9 @@ importers:
next-i18next:
specifier: ^13.3.0
version: 13.3.0(i18next@22.5.1)(next@13.1.6)(react-i18next@12.3.1)(react@18.2.0)
node-cron:
specifier: ^3.0.3
version: 3.0.3
nprogress:
specifier: ^0.2.0
version: 0.2.0
@@ -25,6 +25,37 @@ subjects:
namespace: license-frontend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: notification-manager
rules:
- apiGroups:
- notification.sealos.io
resources:
- notifications
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: license-frontend-notification-manager-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: notification-manager
subjects:
- kind: ServiceAccount
name: license-frontend
namespace: license-frontend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: license-frontend-node-reader-rolebinding
@@ -96,6 +127,15 @@ spec:
# do not modify this image, it is used for CI/CD
image: ghcr.io/labring/sealos-license-frontend:latest
imagePullPolicy: Always
readinessProbe:
httpGet:
path: /api/cronjob/init
port: 3000
initialDelaySeconds: 5
periodSeconds: 3
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
volumeMounts:
- name: license-frontend-volume
mountPath: /config.yaml
+1
View File
@@ -40,6 +40,7 @@
"nanoid": "^4.0.2",
"next": "13.1.6",
"next-i18next": "^13.3.0",
"node-cron": "^3.0.3",
"nprogress": "^0.2.0",
"prettier": "^2.8.8",
"react": "18.2.0",
@@ -6,3 +6,8 @@ export const applyLicense = (yamlList: string[], type: 'create' | 'replace' | 'u
export const getLicenseRecord = ({ page = 1, pageSize = 10 }: { page: number; pageSize: number }) =>
GET<LicenseCR[]>('/api/license/getLicense', { page, pageSize });
export const getLicenseByName = ({ name }: { name: string }) =>
GET<LicenseCR>(`/api/license/get?name=${name}`);
export const checkLicenses = () => GET(`/api/license/checkLicenses`);
@@ -28,9 +28,10 @@ interface Props extends BoxProps {
fileExtension: string;
files: FileItemType[];
setFiles: React.Dispatch<React.SetStateAction<FileItemType[]>>;
multiple: boolean;
}
const FileSelect = ({ fileExtension, setFiles, files, ...props }: Props) => {
const FileSelect = ({ fileExtension, setFiles, files, multiple, ...props }: Props) => {
const { Loading: FileSelectLoading } = useLoading();
const { t } = useTranslation();
const { toast } = useToast();
@@ -39,7 +40,7 @@ const FileSelect = ({ fileExtension, setFiles, files, ...props }: Props) => {
const { File, onOpen } = useSelectFile({
fileType: fileExtension,
multiple: true
multiple: multiple
});
const onSelectFile = useCallback(
@@ -49,8 +50,6 @@ const FileSelect = ({ fileExtension, setFiles, files, ...props }: Props) => {
// Parse file by file
let promise = Promise.resolve<FileItemType[]>([]);
files.forEach((file) => {
console.log(file, 'fiel');
promise = promise.then(async (result) => {
const extension = file?.name?.split('.')?.pop()?.toLowerCase();
@@ -0,0 +1 @@
export const LicenseFrontendKey = 'cloud.sealos.io/license-frontend';
@@ -0,0 +1,33 @@
import { jsonRes } from '@/services/backend/response';
import type { NextApiRequest, NextApiResponse } from 'next';
const cron = require('node-cron');
let hasAddedCron = false;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const baseurl = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}`;
if (process.env.NODE_ENV !== 'production' && !hasAddedCron) {
hasAddedCron = true;
cron.schedule(
'0 10 * * *',
async () => {
try {
const result = await (await fetch(`${baseurl}/api/license/checkLicenses`)).json();
console.log(result);
} catch (error) {
console.log(error);
}
},
{
scheduled: true,
timezone: 'Asia/Shanghai'
}
);
}
jsonRes(res, {
data: hasAddedCron
? 'Cron job is set up and running in production.'
: 'Cron job setup skipped in development.'
});
}
@@ -0,0 +1,190 @@
import { LicenseFrontendKey } from '@/constants/key';
import { K8sApiDefault, createYaml } from '@/services/backend/kubernetes';
import { jsonRes } from '@/services/backend/response';
import { LicenseCR, NotificationCR } from '@/types';
import { decodeJWT } from '@/utils/crypto';
import { json2Notification } from '@/utils/json2Yaml';
import { formatTimeToDay } from '@/utils/tools';
import * as k8s from '@kubernetes/client-node';
import type { NextApiRequest, NextApiResponse } from 'next';
const createLicenseNotification = (licenses: LicenseCR[], timeUntilExpiration: number) => {
if (licenses.length === 0) {
return json2Notification({
namespace: 'sealos',
name: `license-notification-${Date.now()}`,
desktopPopup: true,
i18ns: {
zh: {
from: 'License',
title: '无有效许可证',
message: '未找到有效许可证。请购买许可证。'
},
en: {
from: 'License',
title: 'No Active License',
message: 'No active licenses found. Please purchase a license.'
}
}
});
}
const daysUntilExpiration = Math.ceil(timeUntilExpiration / (24 * 60 * 60));
if (timeUntilExpiration <= 0) {
return json2Notification({
namespace: 'sealos',
name: `license-notification-${Date.now()}`,
desktopPopup: true,
i18ns: {
zh: {
from: 'License',
title: '许可证已过期',
message: '您的许可证已过期。请立即续期。'
},
en: {
from: 'License',
title: 'License Expired',
message: 'Your license has expired. Please renew it immediately.'
}
}
});
} else if (daysUntilExpiration <= 30) {
return json2Notification({
namespace: 'sealos',
name: `license-notification-${Date.now()}`,
desktopPopup: true,
i18ns: {
zh: {
from: 'License',
title: '许可证即将过期',
message: `您的许可证将在 ${daysUntilExpiration} 天后过期。请考虑续期。`
},
en: {
from: 'License',
title: 'License Expiring Soon',
message: `Your license will expire in ${daysUntilExpiration} days. Please consider renewing.`
}
}
});
}
return null;
};
const listNotifications = async (namespace: string): Promise<NotificationCR[]> => {
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CustomObjectsApi);
try {
const response = (await k8sApi.listNamespacedCustomObject(
'notification.sealos.io',
'v1',
namespace,
'notifications',
undefined,
undefined,
undefined,
undefined,
`${LicenseFrontendKey}`
)) as {
body: {
items: NotificationCR[];
};
};
const notifications = response.body.items;
return notifications;
} catch (err) {
throw err;
}
};
const deleteNotification = async (namespace: string, name: string) => {
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CustomObjectsApi);
try {
await k8sApi.deleteNamespacedCustomObject(
'notification.sealos.io',
'v1',
namespace,
'notifications',
name
);
} catch (err) {
throw err;
}
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const defaultKc = K8sApiDefault();
const response = (await defaultKc
.makeApiClient(k8s.CustomObjectsApi)
.listClusterCustomObject('license.sealos.io', 'v1', 'licenses')) as {
body: {
items: LicenseCR[];
};
};
const licenses = response.body.items.filter((item) => item.status.phase === 'Active');
// Delete Old Notifications
const notifications = await listNotifications('sealos');
for (const notification of notifications) {
await deleteNotification(notification.metadata.namespace, notification.metadata.name);
}
if (licenses.length === 0) {
const noLicenseNotification = createLicenseNotification(licenses, 0);
noLicenseNotification && (await createYaml(defaultKc, [noLicenseNotification]));
return jsonRes(res, {
data: {
message: 'No licenses found'
}
});
}
const latestLicense = licenses.reduce((latest, current) => {
const latestExp = decodeJWT(latest.spec.token)?.exp || 0;
const currentExp = decodeJWT(current.spec.token)?.exp || 0;
return currentExp > latestExp ? current : latest;
});
const maxExpTime = decodeJWT(latestLicense.spec.token)?.exp;
if (!maxExpTime) {
throw new Error('Failed to decode expiration time from latest license');
}
const now = Math.floor(Date.now() / 1000); // current time in seconds
const timeUntilExpiration = maxExpTime - now; // time until expiration in seconds
const isExpired = timeUntilExpiration <= 0;
const notification = createLicenseNotification(licenses, timeUntilExpiration);
notification && (await createYaml(defaultKc, [notification]));
jsonRes(res, {
data: {
latestLicense: {
name: latestLicense.metadata.name,
namespace: latestLicense.metadata.namespace,
expirationTime: new Date(maxExpTime * 1000).toString(),
timeUntilExpiration: formatTimeToDay(timeUntilExpiration),
isExpired: isExpired
},
totalActiveLicenses: licenses.length
}
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}
@@ -0,0 +1,32 @@
import { authSession } from '@/services/backend/auth';
import { getK8s } from '@/services/backend/kubernetes';
import { jsonRes } from '@/services/backend/response';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { name } = req.query as { name: string };
const { k8sCustomObjects, namespace } = await getK8s({
kubeconfig: await authSession(req)
});
const response = (await k8sCustomObjects.getNamespacedCustomObject(
'license.sealos.io',
'v1',
namespace,
'licenses',
name
)) as {
body: any;
};
jsonRes(res, {
data: response.body
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}
@@ -1,6 +1,7 @@
import { authSession } from '@/services/backend/auth';
import { getK8s } from '@/services/backend/kubernetes';
import { jsonRes } from '@/services/backend/response';
import { LicenseCR } from '@/types';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
@@ -20,15 +21,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
undefined
)) as {
body: {
items: [];
items: LicenseCR[];
};
};
const result = response.body.items.filter((item) => item.status.phase === 'Active');
jsonRes(res, {
data: response.body.items
data: result
});
} catch (err) {
console.log(err, 'getlicense----');
jsonRes(res, {
code: 500,
error: err
+40 -21
View File
@@ -1,4 +1,4 @@
import { applyLicense, getLicenseRecord } from '@/api/license';
import { applyLicense, checkLicenses, getLicenseByName, getLicenseRecord } from '@/api/license';
import { getClusterId, getPlatformEnv } from '@/api/platform';
import FileSelect, { FileItemType } from '@/components/FileSelect';
import MyIcon from '@/components/Icon';
@@ -12,7 +12,7 @@ import { Box, Center, Divider, Flex, Image, Text } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { debounce } from 'lodash';
import { useTranslation } from 'next-i18next';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
export default function LicenseApp() {
const { t } = useTranslation();
@@ -20,22 +20,41 @@ export default function LicenseApp() {
const { toast } = useToast();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const { copyData } = useCopyData();
const [purchaseLink, setPurchaseLink] = useState('');
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const queryClient = useQueryClient();
const licenseMutation = useMutation({
mutationFn: (yamlList: string[]) => applyLicense(yamlList, 'create'),
onSuccess(data) {
console.log(data, 'data');
toast({
title: t('Activation Successful'),
status: 'success'
});
const licenseMutation = useMutation(['licenseMutation'], {
mutationFn: () => {
const licenseFile = files[0].text;
const licenseStr = json2License(licenseFile).yamlStr;
return applyLicense([licenseStr], 'create');
},
async onSuccess() {
const licenseFile = files[0].text;
const licenseObj = json2License(licenseFile).yamlObj;
const result = await getLicenseByName({ name: licenseObj.metadata.name });
if (result.status.phase !== 'Active') {
toast({
title: result.status.reason,
status: 'error'
});
} else {
await checkLicenses();
toast({
title: t('Activation Successful'),
status: 'success'
});
}
queryClient.invalidateQueries(['getLicenseActive']);
},
onError(error: { message?: string }) {
console.log(error);
if (error?.message && typeof error?.message === 'string') {
toast({
title: error.message,
@@ -81,8 +100,7 @@ export default function LicenseApp() {
status: 'error'
});
}
const yamlList = files.map((item) => json2License(item.text));
licenseMutation.mutate(yamlList);
licenseMutation.mutate();
}, 500);
const downloadToken = (token: string) => {
@@ -91,15 +109,16 @@ export default function LicenseApp() {
};
const maxExpTime = useMemo(() => {
if (data) {
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
if (data && data.length > 0) {
const maxItem = data.reduce((item, license) => {
const maxTime = decodeJWT(item.spec.token)?.exp || 0;
const currentTime = decodeJWT(license.spec.token)?.exp || 0;
const maxTime = decodeJWT(item.spec.token)?.exp || currentTimeInSeconds;
const currentTime = decodeJWT(license.spec.token)?.exp || currentTimeInSeconds;
return currentTime > maxTime ? license : item;
});
return decodeJWT(maxItem.spec.token)?.exp || 0;
return decodeJWT(maxItem.spec.token)?.exp || currentTimeInSeconds;
} else {
return 0;
return currentTimeInSeconds;
}
}, [data]);
@@ -140,7 +159,7 @@ export default function LicenseApp() {
</Text>
<Text fontSize={'12px'}>
{t('expire_date')}: {formatTime(maxExpTime * 1000)}
{t('expire_date')}: {isClient && formatTime(maxExpTime * 1000)}
</Text>
<Divider my={'28px'} borderColor={'rgba(255, 255, 255, 0.05)'} />
@@ -178,7 +197,7 @@ export default function LicenseApp() {
<Text color={'#262A32'} fontSize={'24px'} fontWeight={600}>
{t('Activate License')}
</Text>
<FileSelect fileExtension={'.yaml'} files={files} setFiles={setFiles} />
<FileSelect multiple={false} fileExtension={'.yaml'} files={files} setFiles={setFiles} />
<Flex
userSelect={'none'}
ml={'auto'}
+1
View File
@@ -2,6 +2,7 @@ import { MongoClient } from 'mongodb';
export * from './license';
export * from './user';
export * from './notification';
declare global {
var mongodb: MongoClient | null;
+2 -1
View File
@@ -62,7 +62,8 @@ export type LicenseCR = {
status: {
activationTime: string;
expirationTime: string;
phase: string;
phase: 'Active' | 'Failed';
reason: string;
};
};
+29
View File
@@ -0,0 +1,29 @@
import * as k8s from '@kubernetes/client-node';
export type NotificationCR = k8s.KubernetesObject & {
apiVersion: 'notification.sealos.io/v1';
kind: 'Notification';
metadata: {
creationTimestamp?: string;
labels: {
isRead: string;
};
name: string;
namespace: string;
uid?: string;
};
spec: {
from: string;
message: string;
timestamp: number;
title: string;
desktopPopup?: boolean;
i18ns?: {
zh?: {
from: string;
message: string;
title: string;
};
};
};
};
@@ -1,10 +1,52 @@
import { LicenseYaml } from '@/types';
import { LicenseFrontendKey } from '@/constants/key';
import { LicenseYaml, NotificationCR } from '@/types';
import yaml from 'js-yaml';
import { getUserNamespace } from './user';
export const json2License = (licenseYaml: string) => {
const namespace = getUserNamespace();
const obj = yaml.load(licenseYaml) as LicenseYaml;
const template: LicenseYaml = obj;
return yaml.dump(template);
return {
yamlStr: yaml.dump(template),
yamlObj: obj
};
};
export const json2Notification = ({
namespace,
name,
desktopPopup,
i18ns
}: {
name: string;
namespace: string;
desktopPopup: boolean;
i18ns: {
zh: { message: string; title: string; from: string };
en: { message: string; title: string; from: string };
};
}) => {
const notification: NotificationCR = {
apiVersion: 'notification.sealos.io/v1',
kind: 'Notification',
metadata: {
labels: {
isRead: 'false',
[LicenseFrontendKey]: name
},
name: name,
namespace: namespace
},
spec: {
from: i18ns.en.from,
message: i18ns.en.message,
timestamp: Math.floor(Date.now() / 1000),
title: i18ns.en.title,
desktopPopup: desktopPopup,
i18ns: {
zh: i18ns.zh
}
}
};
return notification;
};
+1
View File
@@ -15,6 +15,7 @@
"@alicloud/openapi-client": "^0.4.6",
"@alicloud/tea-util": "^1.4.7",
"@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+14
View File
@@ -17,6 +17,9 @@ dependencies:
'@chakra-ui/anatomy':
specifier: ^2.2.2
version: 2.2.2
'@chakra-ui/icons':
specifier: ^2.1.1
version: 2.1.1(@chakra-ui/system@2.6.1)(react@18.0.0)
'@chakra-ui/react':
specifier: ^2.8.1
version: 2.8.1(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.0.0)(framer-motion@10.16.4)(react-dom@18.0.0)(react@18.0.0)
@@ -600,6 +603,17 @@ packages:
react: 18.0.0
dev: false
/@chakra-ui/icons@2.1.1(@chakra-ui/system@2.6.1)(react@18.0.0):
resolution: {integrity: sha512-3p30hdo4LlRZTT5CwoAJq3G9fHI0wDc0pBaMHj4SUn0yomO+RcDRlzhdXqdr5cVnzax44sqXJVnf3oQG0eI+4g==}
peerDependencies:
'@chakra-ui/system': '>=2.0.0'
react: '>=18'
dependencies:
'@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.1)(react@18.0.0)
'@chakra-ui/system': 2.6.1(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.0.0)
react: 18.0.0
dev: false
/@chakra-ui/image@2.1.0(@chakra-ui/system@2.6.1)(react@18.0.0):
resolution: {integrity: sha512-bskumBYKLiLMySIWDGcz0+D9Th0jPvmX6xnRMs4o92tT3Od/bW26lahmV2a2Op2ItXeCmRMY+XxJH5Gy1i46VA==}
peerDependencies:
+9 -8
View File
@@ -1,29 +1,30 @@
import { ActiveClusterParams } from '@/pages/api/cluster/activeCluster';
import { DELETE, GET, POST } from '@/services/request';
import { ClusterResult, CreateClusterParams } from '@/types';
import { ClusterDB, CreateClusterParams } from '@/types';
export const createCluster = (payload: CreateClusterParams) => POST('/api/cluster/create', payload);
export const createCluster = (payload: CreateClusterParams) =>
POST<ClusterDB>('/api/cluster/create', payload);
export const getClusterList = ({ page, pageSize }: { page: number; pageSize: number }) =>
POST<{ total: number; records: ClusterResult[] }>('/api/cluster/getClusterList', {
POST<{ total: number; records: ClusterDB[] }>('/api/cluster/getClusterList', {
page,
pageSize
});
export const findClusterById = (payload: { clusterId: string }) =>
GET<ClusterResult>('/api/cluster/findById', payload);
GET<ClusterDB>('/api/cluster/findById', payload);
export const updateClusterName = (payload: { clusterId: string; displayName: string }) =>
POST<ClusterResult>('/api/cluster/updateName', payload);
POST<ClusterDB>('/api/cluster/updateName', payload);
export const deleteClusterById = (payload: { clusterId: string }) =>
DELETE<ClusterResult>('/api/cluster/delete', payload);
DELETE<ClusterDB>('/api/cluster/delete', payload);
export const isKubeSystemIDBound = (id: string) =>
GET<{ isBound: boolean }>('/api/cluster/isBound', { kubeSystemID: id });
export const activeClusterBySystemId = (payload: ActiveClusterParams) =>
POST<ClusterResult>('/api/cluster/activeCluster', payload);
POST<ClusterDB>('/api/cluster/activeCluster', payload);
export const findClusterBySystemId = (payload: { systemId: string }) =>
GET<ClusterResult>('/api/cluster/findBySystemId', payload);
GET<ClusterDB>('/api/cluster/findBySystemId', payload);
@@ -0,0 +1,14 @@
import { Icon, IconProps } from '@chakra-ui/react';
export const ArrowDown = (props: IconProps) => {
return (
<Icon {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.64645 4.14718C2.84171 3.95192 3.15829 3.95192 3.35355 4.14718L6 6.79363L8.64645 4.14718C8.84171 3.95192 9.15829 3.95192 9.35355 4.14718C9.54882 4.34244 9.54882 4.65902 9.35355 4.85429L6.35355 7.85429C6.15829 8.04955 5.84171 8.04955 5.64645 7.85429L2.64645 4.85429C2.45118 4.65902 2.45118 4.34244 2.64645 4.14718Z"
fill="#667085"
/>
</Icon>
);
};
@@ -0,0 +1,14 @@
import { Icon, IconProps } from '@chakra-ui/react';
export const ArrowUp = (props: IconProps) => {
return (
<Icon {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 13" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.64645 8.62626C2.84171 8.82152 3.15829 8.82152 3.35355 8.62626L6 5.97981L8.64645 8.62626C8.84171 8.82152 9.15829 8.82152 9.35355 8.62626C9.54882 8.431 9.54882 8.11441 9.35355 7.91915L6.35355 4.91915C6.15829 4.72389 5.84171 4.72389 5.64645 4.91915L2.64645 7.91915C2.45118 8.11441 2.45118 8.431 2.64645 8.62626Z"
fill="#667085"
/>
</Icon>
);
};
@@ -0,0 +1,21 @@
import { Icon, IconProps } from '@chakra-ui/react';
export const CpuIcon = (props: IconProps) => {
return (
<Icon
{...props}
xmlns="http://www.w3.org/2000/svg"
width="17px"
height="16px"
viewBox="0 0 17 16"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.50786 1.66642C6.50786 1.29823 6.80634 0.999756 7.17453 0.999756C7.54272 0.999756 7.8412 1.29823 7.8412 1.66642V2.71305H9.57453V1.66642C9.57453 1.29823 9.87301 0.999756 10.2412 0.999756C10.6094 0.999756 10.9079 1.29823 10.9079 1.66642V2.71305H11.4615C12.8606 2.71305 13.9949 3.84727 13.9949 5.24639V5.8001H15.0415C15.4097 5.8001 15.7082 6.09858 15.7082 6.46677C15.7082 6.83496 15.4097 7.13343 15.0415 7.13343H13.9949V8.86675H15.0415C15.4097 8.86675 15.7082 9.16523 15.7082 9.53342C15.7082 9.90161 15.4097 10.2001 15.0415 10.2001H13.9949V10.7538C13.9949 12.1529 12.8606 13.2871 11.4615 13.2871H10.9079V14.3337C10.9079 14.7019 10.6094 15.0004 10.2412 15.0004C9.87301 15.0004 9.57453 14.7019 9.57453 14.3337V13.2871H7.8412V14.3337C7.8412 14.7019 7.54272 15.0004 7.17453 15.0004C6.80634 15.0004 6.50786 14.7019 6.50786 14.3337V13.2871H5.95413C4.55501 13.2871 3.4208 12.1529 3.4208 10.7538V10.2001H2.37419C2.006 10.2001 1.70752 9.90161 1.70752 9.53342C1.70752 9.16523 2.006 8.86675 2.37419 8.86675H3.4208V7.13343H2.37419C2.006 7.13343 1.70752 6.83496 1.70752 6.46677C1.70752 6.09858 2.006 5.8001 2.37419 5.8001H3.4208V5.24639C3.4208 3.84727 4.55501 2.71305 5.95413 2.71305H6.50786V1.66642ZM5.95413 4.04639C5.29139 4.04639 4.75413 4.58364 4.75413 5.24639V10.7538C4.75413 11.4165 5.29139 11.9538 5.95413 11.9538H11.4615C12.1243 11.9538 12.6615 11.4165 12.6615 10.7538V5.24639C12.6615 4.58364 12.1243 4.04639 11.4615 4.04639H5.95413Z"
fill="#667085"
/>
</Icon>
);
};
@@ -0,0 +1,29 @@
import { Icon, IconProps } from '@chakra-ui/react';
export const MemoryIcon = (props: IconProps) => {
return (
<Icon
{...props}
xmlns="http://www.w3.org/2000/svg"
width="17px"
height="16px"
viewBox="0 0 17 16"
fill="none"
>
<path
d="M5.4988 5.19759C5.4988 4.8294 5.79728 4.53092 6.16547 4.53092C6.53366 4.53092 6.83213 4.8294 6.83213 5.19759V6.53092C6.83213 6.89911 6.53366 7.19759 6.16547 7.19759C5.79728 7.19759 5.4988 6.89911 5.4988 6.53092V5.19759Z"
fill="#667085"
/>
<path
d="M8.99276 4.53092C8.62457 4.53092 8.32609 4.8294 8.32609 5.19759V6.53092C8.32609 6.89911 8.62457 7.19759 8.99276 7.19759C9.36095 7.19759 9.65942 6.89911 9.65942 6.53092V5.19759C9.65942 4.8294 9.36095 4.53092 8.99276 4.53092Z"
fill="#667085"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.64966 3.67799C2.64966 2.38155 3.70063 1.33057 4.99708 1.33057H9.04735C9.66993 1.33057 10.267 1.57788 10.7072 2.01811L13.3706 4.6815C13.8109 5.12173 14.0582 5.71881 14.0582 6.34138V12.3219C14.0582 13.6183 13.0072 14.6693 11.7107 14.6693H4.99708C3.70063 14.6693 2.64966 13.6183 2.64966 12.3219V3.67799ZM4.99708 2.6639C4.43701 2.6639 3.98299 3.11792 3.98299 3.67799V12.3219C3.98299 12.8819 4.43701 13.336 4.99708 13.336H11.7107C12.2708 13.336 12.7248 12.8819 12.7248 12.3219V6.34138C12.7248 6.07243 12.618 5.81449 12.4278 5.62431L9.76442 2.96092C9.57424 2.77074 9.31631 2.6639 9.04735 2.6639H4.99708Z"
fill="#667085"
/>
</Icon>
);
};
@@ -86,7 +86,6 @@ export default function Layout({ children }: { children: ReactNode }) {
);
})}
</Flex>
{/* <LangSelectSimple /> */}
<Box ml="auto"></Box>
<Account />
</Flex>
@@ -0,0 +1,155 @@
import React, { useRef, forwardRef, useMemo } from 'react';
import {
Menu,
Box,
MenuList,
MenuItem,
Button,
useDisclosure,
useOutsideClick,
MenuButton
} from '@chakra-ui/react';
import type { ButtonProps } from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
interface Props extends ButtonProps {
width?: string;
height?: string;
value?: string;
placeholder?: string;
list: {
label: string | React.ReactNode;
value: string;
}[];
onchange?: (val: string) => void;
isInvalid?: boolean;
}
const MySelect = (
{
placeholder,
value,
width = 'auto',
height = '30px',
list,
onchange,
isInvalid,
...props
}: Props,
selectRef: any
) => {
const ref = useRef<HTMLButtonElement>(null);
const SelectRef = useRef<HTMLDivElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
useOutsideClick({
ref: SelectRef,
handler: () => {
onClose();
}
});
const activeMenu = useMemo(() => list.find((item) => item.value === value), [list, value]);
return (
<Menu autoSelect={false} isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<Box
ref={SelectRef}
position={'relative'}
onClick={() => {
isOpen ? onClose() : onOpen();
}}
>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
width={width}
height={height}
ref={ref}
display={'flex'}
alignItems={'center'}
justifyContent={'space-between'}
border={'1px solid #E8EBF0'}
borderRadius={'md'}
fontSize={'12px'}
fontWeight={'400'}
variant={'outline'}
_hover={{
borderColor: '#94B5FF',
bg: 'rgba(17, 24, 36, 0.05)'
}}
_active={{
transform: ''
}}
{...(isOpen
? {
boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)',
borderColor: '#487FFF',
bg: '#FFF'
}
: {
bg: '#F7F8FA',
borderColor: isInvalid ? 'red' : ''
})}
{...props}
>
{activeMenu ? (
<>
<Box>{activeMenu.label}</Box>
</>
) : (
<>
<Box>{placeholder}</Box>
</>
)}
</MenuButton>
<MenuList
minW={(() => {
const w = ref.current?.clientWidth;
if (w) {
return `${w}px !important`;
}
return Array.isArray(width)
? width.map((item) => `${item} !important`)
: `${width} !important`;
})()}
p={'6px'}
borderRadius={'base'}
border={'1px solid #E8EBF0'}
boxShadow={
'0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)'
}
zIndex={99}
overflow={'overlay'}
maxH={'300px'}
>
{list.map((item) => (
<MenuItem
key={item.value}
{...(value === item.value
? {
color: 'brightBlue.600'
}
: {})}
borderRadius={'4px'}
_hover={{
bg: 'rgba(17, 24, 36, 0.05)',
color: '#3370FF'
}}
onClick={() => {
if (onchange && value !== item.value) {
onchange(item.value);
}
}}
>
<Box>{item.label}</Box>
</MenuItem>
))}
</MenuList>
</Box>
</Menu>
);
};
export default forwardRef(MySelect);
@@ -0,0 +1,302 @@
import { ArrowDown } from '@/components/Icon/ArrowDown';
import { ArrowUp } from '@/components/Icon/ArrowUp';
import { CpuIcon } from '@/components/Icon/CpuIcon';
import { MemoryIcon } from '@/components/Icon/MemoryIcon';
import MySelect from '@/components/Select';
import {
CpuSlideMarkList,
MemorySlideMarkList,
MonthMapList,
cpuPriceMonth,
freeClusterForm,
memoryPriceMonth
} from '@/constant/product';
import { ClusterFormType } from '@/types';
import {
Box,
Divider,
Flex,
Input,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Slider,
SliderFilledTrack,
SliderMark,
SliderThumb,
SliderTrack,
Text
} from '@chakra-ui/react';
import { forwardRef } from 'react';
import { Controller, UseFormReturn } from 'react-hook-form';
const BillingMeter = forwardRef(
(
{
formHook,
priceMode = true,
price,
isPayLicense = false
}: {
formHook: UseFormReturn<ClusterFormType, any, undefined>;
price: number;
priceMode?: boolean;
isPayLicense?: boolean;
},
ref
) => {
const { setValue, getValues, register, control } = formHook;
const formArr = [
{
unit: '核',
value: 'cpu' as 'cpu' | 'memory',
label: 'CPU',
min: 1,
max: 9999,
sliderList: CpuSlideMarkList
},
{
unit: 'G',
value: 'memory' as 'cpu' | 'memory',
label: '内存',
min: 1,
max: 9999,
sliderList: MemorySlideMarkList
}
];
return (
<Box minW={'500px'}>
{priceMode && (
<Text mt={'40px'} textAlign={'center'} fontSize={'24px'} fontWeight={500}>
计价方案
</Text>
)}
<Box mt={priceMode ? '24px' : ''} borderRadius={'8px'} bg={'white'} px={'32px'}>
{priceMode && (
<>
<Flex
color={'#667085'}
fontSize={'12px'}
alignItems={'center'}
fontWeight={500}
py={'16px'}
>
<Text color={'#485264'}>单价</Text>
<Flex gap={'4px'} ml={'auto'}>
<CpuIcon />
<Text>CPU: {cpuPriceMonth * 12} 元/核心/年</Text>
</Flex>
<Flex gap={'4px'} ml={'24px'}>
<MemoryIcon />
<Text>内存: {memoryPriceMonth * 12} 元/G/年</Text>
</Flex>
</Flex>
<Divider borderColor={'#E8EBF0'} />
</>
)}
{!priceMode && !isPayLicense && (
<Flex alignItems={'center'} pt={'32px'}>
<Box fontSize={'14px'} fontWeight={500} minW={'88px'}>
集群名
</Box>
<Input
flex={1}
borderRadius={'6px'}
placeholder={'集群名'}
{...register('name', {
required: '集群名称不能为空'
})}
/>
</Flex>
)}
{formArr.map((item) => {
const list = item.sliderList;
return (
<Flex
key={item.value}
flexDirection={priceMode ? 'column' : 'row'}
mt={priceMode ? '20px' : '32px'}
>
<Box minW={'88px'} fontWeight={500} fontSize={priceMode ? '12px' : '14px'}>
{item.label}
</Box>
<Flex alignItems={'center'} gap={'20px'} flex={1} mt={priceMode ? '12px' : ''}>
<Controller
control={control}
name={item.value}
render={({ field }) => (
<Slider
alignSelf={'self-start'}
{...field}
mt={'4px'}
focusThumbOnChange={false}
aria-label="slider-ex-1"
min={item.min}
max={item.value === 'cpu' ? 2048 : 4096}
>
{list.map((n, i) => (
<SliderMark
key={n.value}
value={n.value}
mt={'6px'}
fontSize={'11px'}
transform={'translateX(-50%)'}
whiteSpace={'nowrap'}
{...(getValues(item.value) === n.value
? { color: '#111824' }
: { color: '#8A95A7' })}
>
{n.value} {i + 1 === CpuSlideMarkList.length && item.unit}
</SliderMark>
))}
<SliderTrack
bg={'#EAEDF3'}
borderRadius={'4px'}
overflow={'hidden'}
h={'4px'}
>
<SliderFilledTrack bg={'#111824'} />
</SliderTrack>
<SliderThumb bg={'#111824'} _focusVisible={{ boxShadow: '' }} />
</Slider>
)}
/>
<Controller
control={control}
name={item.value}
render={({ field }) => (
<NumberInput
ml={'10px'}
position={'relative'}
max={item.max}
min={item.min}
{...field}
onChange={(valueString) => {
const value = Number(valueString);
field.onChange(valueString === '' || isNaN(value) ? item.min : value);
}}
>
<NumberInputField
width={'89px'}
height={'32px'}
pl={'12px'}
color={'#111824'}
fontSize={'12px'}
min={item.min}
max={item.max}
borderRadius={'md'}
borderColor={'#E8EBF0'}
bg={'#F7F8FA'}
_focusVisible={{
borderColor: 'brightBlue.500',
boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)',
bg: '#FFF',
color: '#111824'
}}
_hover={{
borderColor: 'brightBlue.300'
}}
/>
<NumberInputStepper>
<NumberIncrementStepper>
<ArrowUp />
</NumberIncrementStepper>
<NumberDecrementStepper>
<ArrowDown />
</NumberDecrementStepper>
</NumberInputStepper>
<Box
zIndex={1}
position={'absolute'}
right={'28px'}
top={'50%'}
fontSize={'12px'}
fontWeight={500}
transform={'translateY(-50%)'}
color={'#667085'}
>
{item.unit}
</Box>
</NumberInput>
)}
/>
</Flex>
</Flex>
);
})}
<Flex flexDirection={priceMode ? 'column' : 'row'} mt={'32px'}>
<Box fontWeight={500} fontSize={priceMode ? '12px' : '14px'} minW={'88px'}>
时间
</Box>
<MySelect
textAlign={'left'}
mt={priceMode ? '12px' : ''}
width={'200px'}
value={getValues('months')}
list={MonthMapList}
onchange={(val: any) => setValue('months', val)}
/>
</Flex>
<Flex
justifyContent={'end'}
textAlign={'right'}
color={'#0884DD'}
fontSize={'12px'}
fontWeight={500}
mt={priceMode ? '24px' : '4px'}
>
<Text
px={'8px'}
cursor={'pointer'}
onClick={() => {
formHook.reset({
cpu: freeClusterForm.cpu,
memory: freeClusterForm.memory,
months: freeClusterForm.months,
name: getValues('name'),
systemId: getValues('systemId')
});
}}
>
免费规模
</Text>
</Flex>
<Divider mt={priceMode ? '8px' : '4px'} borderColor={'#E8EBF0'} />
<Flex
alignItems={'center'}
justifyContent={'center'}
pb={'16px'}
pt={priceMode ? '16px' : '32px'}
>
<Text color={'#667085'} fontSize={'11px'} fontWeight={500}>
{priceMode ? '价格:' : '需支付:'}
</Text>
<Text
ml={'12px'}
color={'#0884DD'}
fontSize={priceMode ? '20px' : '28px'}
fontWeight={500}
>
¥ {price}
</Text>
</Flex>
</Box>
</Box>
);
}
);
BillingMeter.displayName = 'BillingMeter';
export default BillingMeter;
@@ -1,10 +1,10 @@
import { getSystemEnv, uploadConvertData } from '@/api/system';
import useAuthList from '@/components/Signin/auth/useAuthList';
import useCustomError from '@/components/Signin/auth/useCustomError';
import Language from '@/components/Signin/auth/useLanguage';
import usePassword from '@/components/Signin/auth/usePassword';
import useProtocol from '@/components/Signin/auth/useProtocol';
import useSms from '@/components/Signin/auth/useSms';
import useAuthList from '@/components/login-module/auth/useAuthList';
import useCustomError from '@/components/login-module/auth/useCustomError';
import Language from '@/components/login-module/auth/useLanguage';
import usePassword from '@/components/login-module/auth/usePassword';
import useProtocol from '@/components/login-module/auth/useProtocol';
import useSms from '@/components/login-module/auth/useSms';
import useSessionStore from '@/stores/session';
import { LoginType } from '@/types';
import {
@@ -0,0 +1,621 @@
import { activeClusterBySystemId, createCluster, findClusterById } from '@/api/cluster';
import { createLicense } from '@/api/license';
import { checkWechatPay, createPayment, handlePaymentResult } from '@/api/payment';
import { getSystemEnv, uploadConvertData } from '@/api/system';
import { AddIcon, LeftIcon, StripeIcon, WechatIcon } from '@/components/Icon';
import WechatPayment from '@/components/WechatPayment';
import { defaulClustertForm, freeClusterForm } from '@/constant/product';
import BillingMeter from '@/components/billing-meter';
import useClusterDetail from '@/stores/cluster';
import usePaymentDataStore from '@/stores/payment';
import {
ClusterFormType,
ClusterType,
CreateClusterParams,
PaymentStatus,
TPayMethod,
WechatPaymentData
} from '@/types';
import { calculatePrice, deFormatMoney } from '@/utils/tools';
import {
Box,
Button,
Checkbox,
Flex,
Link,
Modal,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Text,
useDisclosure,
useToast
} from '@chakra-ui/react';
import { loadStripe } from '@stripe/stripe-js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import { useForm } from 'react-hook-form';
const RechargeComponent = forwardRef(function RechargeComponent(
{ isLicensePay }: { isLicensePay: boolean },
ref
) {
const { clusterDetail, setClusterDetail } = useClusterDetail();
const router = useRouter();
const queryClient = useQueryClient();
const toast = useToast({ position: 'top', duration: 2000 });
const { t } = useTranslation();
const [isAgree, setIsAgree] = useState(false);
const [isInvalid, setIsInvalid] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
// 整个流程跑通需要状态管理, 0 初始态, 1 创建支付单, 2 支付中, 3 支付成功
const [complete, setComplete] = useState<0 | 1 | 2 | 3>(0);
const [payType, setPayType] = useState<TPayMethod>('wechat');
const [detail, setDetail] = useState(false);
const [orderID, setOrderID] = useState('');
const [wechatPaymentData, setWechatPaymentData] = useState<WechatPaymentData>();
const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv);
// 用于避免微信支付,窗口关闭后感知不到的问题
const { paymentData, setPaymentData, deletePaymentData, isExpired } = usePaymentDataStore();
const [price, setPrice] = useState(0);
const formHook = useForm<ClusterFormType>();
// price
formHook.watch((data) => {
if (!data) return;
const price = calculatePrice(data as ClusterFormType, freeClusterForm);
setPrice(price);
});
const onClosePayment = useCallback(() => {
formHook.reset();
setOrderID('');
setComplete(0);
setDetail(false);
setIsAgree(false);
onClose();
}, [formHook, onClose]);
useImperativeHandle(
ref,
() => {
return {
onOpen(form?: ClusterFormType) {
onOpen();
if (isLicensePay) {
formHook.reset({
cpu: clusterDetail?.cpu ?? defaulClustertForm.cpu,
memory: clusterDetail?.memory ?? defaulClustertForm.memory,
name: clusterDetail?.displayName ?? defaulClustertForm.name,
months: clusterDetail?.months ?? defaulClustertForm.months
});
} else {
formHook.reset(form ? form : defaulClustertForm);
}
}
};
},
[
onOpen,
isLicensePay,
formHook,
clusterDetail?.cpu,
clusterDetail?.memory,
clusterDetail?.displayName,
clusterDetail?.months
]
);
const handleSubmit = (payType: TPayMethod) => {
formHook.handleSubmit(
(data) => {
setPayType(payType);
if (!isAgree) {
return toast({
status: 'error',
title: t('Please read and agree to the agreement'),
isClosable: true,
position: 'top'
});
}
if (price === 0) {
if (isLicensePay) {
return licenseMutation.mutate({
orderID: '',
clusterId: clusterDetail?.clusterId || '',
...data
});
} else {
return clusterMutation.mutate({
orderID: '',
type: ClusterType.Standard,
...data
});
}
}
if (payType === 'stripe' && price < 10) {
return toast({
status: 'error',
title: t('Pay Minimum Tips')
});
}
setDetail(true);
setComplete(1);
paymentMutation.mutate({ amount: deFormatMoney(price).toString() });
},
(err) => {
const deepSearch = (obj: any): string => {
if (!obj || typeof obj !== 'object') return 'Submit Error';
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
toast({
title: deepSearch(err),
status: 'error',
position: 'top',
duration: 3000,
isClosable: true
});
}
)();
};
const createPaymentMutation = async ({ amount }: { amount: string }) => {
const payMethod = payType;
const currency = 'CNY';
const stripeSuccessCallBackUrl = isLicensePay
? `/cluster?tab=license&stripeState=success`
: '/pricing?stripeState=success';
const stripeErrorCallBackUrl = isLicensePay
? `/cluster?tab=license&stripeState=error`
: '/pricing?stripeState=error';
return createPayment({
amount,
payMethod,
currency,
stripeSuccessCallBackUrl,
stripeErrorCallBackUrl
});
};
const paymentMutation = useMutation(createPaymentMutation, {
async onSuccess(data) {
setPaymentData({
orderId: data.orderID,
clusterId: clusterDetail?.clusterId || '',
...formHook.getValues()
});
if (payType === 'stripe' && platformEnv && data?.sessionID) {
const stripe = await loadStripe(platformEnv?.stripePub);
stripe?.redirectToCheckout({
sessionId: data.sessionID
});
}
if (payType === 'wechat' && data?.tradeNO && data?.codeURL) {
setOrderID(data.orderID);
setWechatPaymentData({ tradeNO: data?.tradeNO, codeURL: data?.codeURL });
setComplete(2);
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
});
const createLicenseMutation = async ({
orderID,
clusterId,
cpu,
memory,
months
}: {
orderID: string;
clusterId: string;
cpu: number;
memory: number;
months: string;
}) => {
return createLicense({ orderID, clusterId, cpu, memory, months });
};
const licenseMutation = useMutation(createLicenseMutation, {
onSuccess() {
onClosePayment();
toast({
status: 'success',
title: t('License issued successfully'),
isClosable: true,
position: 'top'
});
queryClient.invalidateQueries(['getLicenseByClusterId']);
deletePaymentData();
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setOrderID('');
setComplete(0);
setDetail(false);
}
});
// create cluster
const clusterMutation = useMutation((payload: CreateClusterParams) => createCluster(payload), {
async onSuccess(data) {
let cluster;
cluster = await findClusterById({
clusterId: data.clusterId
});
const systemId = formHook.getValues('systemId') || paymentData?.systemId;
if (systemId) {
cluster = await activeClusterBySystemId({
clusterId: cluster.clusterId,
kubeSystemID: systemId
});
}
setClusterDetail(cluster);
toast({
status: 'success',
title: '集群创建成功, 请前往我的集群查看',
isClosable: true,
position: 'top'
});
queryClient.invalidateQueries(['getClusterList']);
queryClient.invalidateQueries({ queryKey: ['getLicenseByClusterId'] });
deletePaymentData();
onClosePayment();
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setOrderID('');
setComplete(0);
setDetail(false);
}
});
useQuery(['getPaymentResult', orderID], () => handlePaymentResult({ orderID }), {
refetchInterval: complete === 2 ? 3 * 1000 : false,
enabled: complete === 2 && !!orderID,
cacheTime: 0,
staleTime: 0,
onSuccess(data) {
const cpu = paymentData?.cpu;
const months = paymentData?.months;
const memory = paymentData?.memory;
const clusterName = paymentData?.name;
const systemId = paymentData?.systemId;
if (!cpu || !memory || !months) {
return toast({
position: 'top',
status: 'error',
title: '缺少 cpu、memory、months 参数'
});
}
if (data.status === PaymentStatus.PaymentSuccess) {
if (isLicensePay) {
if (!clusterDetail?.clusterId) {
return toast({
position: 'top',
status: 'error',
title: '缺少 clusterId 参数'
});
}
licenseMutation.mutate({
orderID: data.orderID,
clusterId: clusterDetail.clusterId,
cpu,
memory,
months
});
} else {
clusterMutation.mutate({
orderID: data.orderID,
type: ClusterType.ScaledStandard,
cpu,
memory,
months,
name: clusterName,
systemId
});
}
uploadConvertData([90])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setOrderID('');
setComplete(0);
setDetail(false);
}
});
// checkWechatPay;
useQuery(['checkWechatPay'], () => checkWechatPay('license'), {
enabled: !isExpired() && !!paymentData?.orderId,
onSuccess(data) {
if (data.status === PaymentStatus.PaymentSuccess) {
const cpu = paymentData?.cpu;
const months = paymentData?.months;
const memory = paymentData?.memory;
const systemId = paymentData?.systemId;
if (!clusterDetail?.clusterId || !cpu || !memory || !months) {
return toast({
position: 'top',
status: 'error',
title: '请联系管理员'
});
}
if (isLicensePay) {
licenseMutation.mutate({
orderID: data.orderID,
clusterId: clusterDetail.clusterId,
cpu,
memory,
months
});
} else {
clusterMutation.mutate({
orderID: data.orderID,
type: ClusterType.ScaledStandard,
cpu,
memory,
months,
systemId
});
}
}
}
});
const getCluster = async (id: string) => {
const cluster = await findClusterById({
clusterId: id
});
setClusterDetail(cluster);
};
useEffect(() => {
const { stripeState, tab } = router.query;
const orderID = paymentData?.orderId;
const clusterId = paymentData?.clusterId;
console.log(stripeState, orderID, clusterId, tab, isLicensePay, paymentData);
const clearQuery = () => {
isLicensePay
? router.replace({
pathname: '/cluster',
query: { tab }
})
: router.replace({
pathname: '/pricing',
query: { tab }
});
};
if (stripeState === 'success') {
toast({
status: 'success',
title: '正在检查支付结果,请耐心等待。',
isClosable: true,
position: 'top'
});
clusterId ? getCluster(clusterId as string) : '';
setComplete(2);
setOrderID(orderID as string);
clearQuery();
} else if (stripeState === 'error') {
clusterId ? getCluster(clusterId as string) : '';
toast({
status: 'error',
duration: 3000,
title: t('Stripe Cancel'),
isClosable: true,
position: 'top'
});
onClosePayment();
clearQuery();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Modal isOpen={isOpen} onClose={onClosePayment}>
<ModalOverlay />
<ModalContent
borderRadius={'12px'}
maxW={'735px'}
minH={'495px'}
display={'flex'}
flexDirection={'column'}
>
<ModalCloseButton />
{detail ? (
<>
<ModalHeader
cursor={'pointer'}
pt={'24px'}
py={'20px'}
onClick={() => {
setOrderID('');
setComplete(0);
setDetail(false);
}}
display={'flex'}
alignItems={'center'}
>
<LeftIcon />
<Text ml="8px" color={'#24282C'}>
{isLicensePay ? '购买 License' : '新建集群'}
</Text>
</ModalHeader>
{complete === 1 ? (
<Flex flex={1} alignItems={'center'} justifyContent={'center'}>
<Spinner size={'xl'} />
</Flex>
) : (
<WechatPayment
complete={complete}
codeURL={wechatPaymentData?.codeURL}
tradeNO={wechatPaymentData?.tradeNO}
/>
)}
</>
) : (
<>
<ModalHeader
height={'56px'}
borderTopRadius={'12px'}
fontSize={'18px'}
fontWeight={500}
bg={'#FBFBFC'}
py={'14px'}
borderBottom={'1px solid #F4F4F7'}
>
{isLicensePay ? '购买 License' : '新建集群'}
</ModalHeader>
<Box px={'20px'} pb="45px">
<BillingMeter
price={price}
formHook={formHook}
priceMode={false}
isPayLicense={isLicensePay}
/>
<Flex justifyContent={'center'}>
{price === 0 ? (
<Button
size="primary"
variant="primary"
w="205px"
h="auto"
py="14px"
px="34px"
onClick={() => handleSubmit('wechat')}
>
<AddIcon fill={'white'} />
<Text ml="12px"> {isLicensePay ? '购买 License' : '新建集群'}</Text>
</Button>
) : (
<Flex gap={'16px'} width={'426px'} h={'44px'}>
{platformEnv?.stripeEnabled && (
<Button
size="primary"
variant="primary"
w="full"
h="auto"
py="14px"
px="34px"
onClick={() => handleSubmit('stripe')}
>
<StripeIcon />
<Text ml="12px">{t('pay with stripe')}</Text>
</Button>
)}
{platformEnv?.wechatEnabledRecharge && (
<Button
size="primary"
variant="primary"
w="full"
h="auto"
py="14px"
px="34px"
onClick={() => handleSubmit('wechat')}
>
<WechatIcon fill={'#33BABB'} />
<Text ml="12px">{t('pay with wechat')}</Text>
</Button>
)}
</Flex>
)}
</Flex>
<Flex justifyContent={'center'} align={'center'} mt={'24px'}>
<Checkbox
isInvalid={isInvalid}
mr={'8px'}
isChecked={isAgree}
variant={'unstyled'}
onChange={(e) => {
setIsInvalid(false);
setIsAgree(e.target.checked);
}}
/>
<Text
fontStyle="normal"
fontWeight="400"
fontSize="12px"
lineHeight="140%"
color={'#1D8CDC'}
>
{t('agree policy')}
<Link
isExternal
href={platformEnv?.service_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Service Agreement')}
</Link>
{t('and')}
<Link
isExternal
href={platformEnv?.private_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Privacy Policy')}
</Link>
</Text>
</Flex>
</Box>
</>
)}
</ModalContent>
</Modal>
);
});
export default RechargeComponent;
@@ -1,98 +0,0 @@
import { getSystemEnv } from '@/api/system';
import { GithubIcon, GoogleIcon, WechatIcon } from '@/components/Icon';
import useSessionStore from '@/stores/session';
import { OauthProvider } from '@/types/user';
import { Button, Flex, Icon } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { MouseEventHandler } from 'react';
const useAuthList = () => {
const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv);
const {
needGithub = false,
needWechat = false,
needGoogle = false,
wechat_client_id = '',
github_client_id = '',
google_client_id = '',
callback_url = ''
} = platformEnv || {};
const { generateState, setProvider } = useSessionStore();
const oauthLogin = async ({ url, provider }: { url: string; provider?: OauthProvider }) => {
setProvider(provider);
window.location.href = url;
};
const authList: { icon: typeof Icon; cb: MouseEventHandler; need: boolean }[] = [
{
// src: '/images/github.svg',
icon: GithubIcon,
cb: (e) => {
e.preventDefault();
const state = generateState();
oauthLogin({
provider: 'github',
url: `https://github.com/login/oauth/authorize?client_id=${github_client_id}&redirect_uri=${callback_url}&scope=user:email%20read:user&state=${state}`
});
},
need: needGithub
},
{
icon: WechatIcon,
cb: (e) => {
e.preventDefault();
const state = generateState();
oauthLogin({
provider: 'wechat',
url: `https://open.weixin.qq.com/connect/qrconnect?appid=${wechat_client_id}&redirect_uri=${callback_url}&response_type=code&state=${state}&scope=snsapi_login&#wechat_redirect`
});
},
need: needWechat
},
{
icon: GoogleIcon,
cb: (e) => {
e.preventDefault();
const state = generateState();
const scope = encodeURIComponent(`https://www.googleapis.com/auth/userinfo.profile openid`);
oauthLogin({
provider: 'google',
//https://www.googleapis.com/auth/userinfo.profile
url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${google_client_id}&redirect_uri=${callback_url}&response_type=code&state=${state}&scope=${scope}&include_granted_scopes=true`
});
},
need: needGoogle
}
];
const AuthList = () => {
return (
<Flex gap={'14px'}>
{authList
.filter((item) => item.need)
.map((item, index) => (
<Button
key={index}
onClick={item.cb}
borderRadius={'full'}
variant={'unstyled'}
size={'xs'}
w="32px"
h="32px"
bgColor={'rgba(255, 255, 255, 0.65)'}
>
<item.icon w="20px" h="20px" />
</Button>
))}
</Flex>
);
};
return {
AuthList
};
};
export default useAuthList;
@@ -1,61 +0,0 @@
import { Flex, Img, Button, Text } from '@chakra-ui/react';
import React, { useState, useEffect } from 'react';
import { WarningIcon, CloseIcon } from '@/components/Icon';
import { useTranslation } from 'next-i18next';
const useCustomError = () => {
const { t } = useTranslation();
const [error, setError] = useState('');
const showError = (errorMessage: string, duration = 5000) => {
setError(errorMessage);
setTimeout(() => {
setError('');
}, duration);
};
const closeError = () => {
setError('');
};
const ErrorComponent = () => {
useEffect(() => {
if (error) {
const timeout = setTimeout(() => {
closeError();
}, 5000); // 默认 5000 毫秒(5秒)后自动关闭错误消息
return () => clearTimeout(timeout);
}
}, []);
return error ? (
<Flex
position={'absolute'}
top="0"
bg={'rgba(249, 78, 97, 1)'}
transform={'translateY(-50%)'}
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
>
<WarningIcon />
<Text color={'#fff'}>{t(error)}</Text>
<Button
variant={'unstyled'}
ml={'auto'}
display={'flex'}
justifyContent={'flex-end'}
onClick={closeError}
>
<CloseIcon />
</Button>
</Flex>
) : null;
};
return { showError, ErrorComponent };
};
export default useCustomError;
@@ -1,27 +0,0 @@
import LangSelectSimple from '@/components/LangSelect';
import { Flex, UseDisclosureReturn } from '@chakra-ui/react';
import { I18n } from 'next-i18next';
type LanguageType = { disclosure: UseDisclosureReturn; i18n: I18n | null };
const Language = ({ disclosure, i18n }: LanguageType) => {
return (
<Flex
alignItems={'center'}
position={'absolute'}
top={'42px'}
right={'42px'}
cursor={'pointer'}
gap={'16px'}
>
<LangSelectSimple
w="36px"
h="36px"
background={'rgba(244, 246, 248, 0.7)'}
boxShadow={'0px 1.2px 2.3px rgba(0, 0, 0, 0.2)'}
/>
</Flex>
);
};
export default Language;
@@ -1,208 +0,0 @@
import { signInByPassword } from '@/api/user';
import { LocklIcon, PersonIcon, VectorIcon } from '@/components/Icon';
import useSessionStore from '@/stores/session';
import { Flex, Input, InputGroup, InputLeftAddon, Text } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
export default function usePassword({
showError
}: {
showError: (errorMessage: string, duration?: number) => void;
}) {
const { t } = useTranslation();
const router = useRouter();
const [userExist, setUserExist] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// 对于注册的用户,需要先验证密码 0 默认页面;1为验证密码页面
const [pageState, setPageState] = useState(0);
const setSession = useSessionStore((s) => s.setSession);
const { register, handleSubmit, watch, trigger, getValues } = useForm<{
username: string;
password: string;
confimPassword: string;
}>();
const login = async () => {
const deepSearch = (obj: any): string => {
if (!obj || typeof obj !== 'object') return t('Submit Error');
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
handleSubmit(
async (data) => {
if (data?.username && data?.password) {
try {
setIsLoading(true);
const result = await signInByPassword(data.username, data.password);
setSession(result);
router.replace('/pricing');
} catch (error: any) {
console.log(error);
showError(t('Invalid username or password'));
} finally {
setIsLoading(false);
}
}
},
(err) => {
console.log(err);
showError(deepSearch(err));
}
)();
};
const PasswordComponent = () => {
if (pageState === 0) {
return <PasswordModal />;
} else {
return <ConfirmPasswordModal />;
}
};
const PasswordModal = () => {
return (
<>
<InputGroup
variant={'unstyled'}
bg="rgba(255, 255, 255, 0.65)"
mt={'20px'}
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
border="1px solid #E5E5E5"
>
<InputLeftAddon>
<PersonIcon />
</InputLeftAddon>
<Input
type="text"
placeholder={t('Username') || ''}
pl={'12px'}
variant={'unstyled'}
fontSize="14px"
id="username"
fontWeight="400"
_autofill={{
backgroundColor: 'transparent !important',
backgroundImage: 'none !important'
}}
{...register('username', {
pattern: {
value: /^[a-zA-Z0-9_-]{3,16}$/,
message: 'username tips'
},
required: true
})}
/>
</InputGroup>
<InputGroup
variant={'unstyled'}
bg="rgba(255, 255, 255, 0.65)"
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
border="1px solid #E5E5E5"
>
<InputLeftAddon>
<LocklIcon />
</InputLeftAddon>
<Input
type="password"
placeholder={t('Password') || ''}
pl={'12px'}
variant={'unstyled'}
fontSize="14px"
id="password"
fontWeight="400"
_autofill={{
backgroundColor: 'transparent !important',
backgroundImage: 'none !important'
}}
{...register('password', {
pattern: {
value: /^(?=.*\S).{8,}$/,
message: 'password tips'
},
required: true
})}
/>
</InputGroup>
</>
);
};
const ConfirmPasswordModal = () => {
return (
<>
<Flex p={'0'} alignItems={'center'} width="266px" minH="42px" mb="14px" borderRadius="4px">
<VectorIcon
fill={'#FFFFFF'}
w={'20px'}
transform={'rotate(-90deg)'}
h={'20px'}
mr={'16px'}
display={'inline-block'}
verticalAlign={'middle'}
cursor={'pointer'}
onClick={() => setPageState(0)}
/>
<Text color={'#FFFFFF'}>{t('Verify password')}</Text>
</Flex>
<InputGroup
variant={'unstyled'}
bg="rgba(255, 255, 255, 0.65)"
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
border="1px solid #E5E5E5"
>
<InputLeftAddon>
<LocklIcon />
</InputLeftAddon>
<Input
type="password"
placeholder={t('Verify password') || 'Verify password'}
pl={'12px'}
fontSize="14px"
id="repassword"
fontWeight="400"
variant={'unstyled'}
_autofill={{
backgroundColor: 'transparent !important',
backgroundImage: 'none !important'
}}
{...register('confimPassword', {
pattern: {
value: /^(?=.*\S).{8,}$/,
message: 'password tips'
},
required: true
})}
/>
</InputGroup>
</>
);
};
return {
PasswordComponent,
login,
userExist,
pageState,
isLoading
};
}
@@ -1,70 +0,0 @@
import { Checkbox, Flex, Link, Text, TextProps } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
const useProtocol = ({
service_protocol,
private_protocol
}: {
service_protocol: string;
private_protocol: string;
}) => {
const { t, i18n } = useTranslation();
const [isAgree, setIsAgree] = useState(false);
const [isInvalid, setIsInvalid] = useState(false);
const Protocol = () => (
<Flex
className="protocol"
w="266px"
alignItems={'center'}
mb={'16px'}
width="266px"
minH="42px"
borderRadius="4px"
p="10px"
>
<Checkbox
isInvalid={isInvalid}
mr={'8px'}
isChecked={isAgree}
variant={'unstyled'}
onChange={(e) => {
setIsInvalid(false);
setIsAgree(e.target.checked);
}}
/>
<Text fontStyle="normal" fontWeight="400" fontSize="12px" lineHeight="140%" color="#FFFFFF">
{t('agree policy')}
<Link
href={service_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Service Agreement')}
</Link>
{t('and')}
<Link
href={private_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Privacy Policy')}
</Link>
</Text>
</Flex>
);
return {
Protocol,
isAgree,
setIsInvalid
};
};
export default useProtocol;
@@ -1,188 +0,0 @@
import { sendCodeByPhone, signInByPhone } from '@/api/user';
import { CodeDoneIcon, SafetyIcon } from '@/components/Icon';
import useSessionStore from '@/stores/session';
import { Input, InputGroup, InputLeftAddon, InputRightAddon, Link, Text } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import NextLink from 'next/link';
import { useRouter } from 'next/router';
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
export default function useSms({
showError
}: {
showError: (errorMessage: string, duration?: number) => void;
}) {
const { t } = useTranslation();
const _remainTime = useRef(0);
const router = useRouter();
const { setSession } = useSessionStore();
const [isLoading, setIsLoading] = useState(false);
const { register, handleSubmit, trigger, getValues } = useForm<{
phoneNumber: string;
verifyCode: string;
}>();
const login = async () => {
const deepSearch = (obj: any): string => {
if (!obj || typeof obj !== 'object') return t('Submit Error');
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
handleSubmit(
async (data) => {
try {
setIsLoading(true);
const result = await signInByPhone(data.phoneNumber, data.verifyCode);
setSession(result);
router.replace('/pricing');
} catch (error) {
console.log(error);
showError(t('Invalid verification code') || 'Invalid verification code');
} finally {
setIsLoading(false);
}
},
(err) => {
showError(deepSearch(err));
}
)();
};
const SmsModal = () => {
const [remainTime, setRemainTime] = useState(_remainTime.current);
useEffect(() => {
if (remainTime <= 0) return;
const interval = setInterval(() => {
setRemainTime(remainTime - 1);
}, 1000);
return () => clearInterval(interval);
}, [remainTime]);
const getCode: MouseEventHandler = async (e) => {
e.preventDefault();
if (!(await trigger('phoneNumber'))) {
showError(t('Invalid phone number') || 'Invalid phone number');
return;
}
setRemainTime(60);
_remainTime.current = 60;
try {
await sendCodeByPhone(getValues('phoneNumber'));
} catch (err) {
showError(t('Get code failed') || 'Get code failed');
setRemainTime(0);
_remainTime.current = 0;
}
};
return (
<>
<InputGroup
variant={'unstyled'}
bg="rgba(255, 255, 255, 0.65)"
mt={'20px'}
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
border="1px solid #E5E5E5"
>
<InputLeftAddon>
<Text pr={'7px'} borderRight="1px solid rgba(0, 0, 0, 0.4)">
+86
</Text>
</InputLeftAddon>
<Input
type="tel"
placeholder={t('phone number tips') || ''}
mx={'12px'}
variant={'unstyled'}
bg={'transparent'}
id="phoneNumber"
fontSize="14px"
fontWeight="400"
_autofill={{
bg: 'transparent'
}}
{...register('phoneNumber', {
pattern: {
value: /^1[3-9]\d{9}$/,
message: 'Invalid mobile number'
},
required: 'Phone number can not be blank'
})}
/>
<InputRightAddon
fontStyle="normal"
fontWeight="500"
fontSize="10px"
lineHeight="20px"
color="#219BF4"
>
{remainTime <= 0 ? (
<Link as={NextLink} href="" onClick={getCode}>
{t('Get Code')}
</Link>
) : (
<Text>{remainTime} s</Text>
)}
</InputRightAddon>
</InputGroup>
<InputGroup
variant={'unstyled'}
bg="rgba(255, 255, 255, 0.65)"
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
border="1px solid #E5E5E5"
>
<InputLeftAddon>
<SafetyIcon />
</InputLeftAddon>
<Input
type="text"
placeholder={t('verify code tips') || ''}
pl={'12px'}
variant={'unstyled'}
id="verifyCode"
fontSize="14px"
fontWeight="400"
_autofill={{
backgroundColor: 'transparent !important',
backgroundImage: 'none !important'
}}
{...register('verifyCode', {
required: 'verify code can not be blank',
pattern: {
value: /^[0-9]{6}$/,
message: 'Invalid verification code'
}
})}
/>
<InputRightAddon>
{getValues('verifyCode')?.length === 6 && <CodeDoneIcon />}
</InputRightAddon>
</InputGroup>
</>
);
};
return {
SmsModal,
login,
isLoading
};
}
@@ -1,44 +0,0 @@
import { Box, Flex, Image, Text } from '@chakra-ui/react';
export default function Background() {
return (
<>
<Image alt="bg" src="/images/bg.svg" zIndex={-100} position={'fixed'} w="100vw" h="100vh" />
<Image
alt="bg"
src="/images/bg-bottom.svg"
zIndex={-98}
position={'fixed'}
w="100vw"
bottom={0}
left={0}
/>
<Image
alt="bg"
src="/images/moon.svg"
zIndex={-96}
position={'fixed'}
w="100vw"
top={0}
left={0}
/>
<Flex position={'fixed'} top={'40px'} left={'42px'} alignItems={'center'}>
<Image
cursor={'pointer'}
p="2px"
width="36px"
height="36px"
src={'/images/sealos.svg'}
fallbackSrc="/images/sealos.svg"
alt="logo"
/>
<Text ml="6px" mr="12px" fontSize={20} fontWeight={700} color={'#fff'} cursor={'pointer'}>
Sealos
</Text>
<Text fontSize={16} fontWeight={600} color={'#fff'}>
| License
</Text>
</Flex>
</>
);
}
@@ -1,207 +0,0 @@
import { getSystemEnv, uploadConvertData } from '@/api/system';
import useAuthList from '@/components/Signin/auth/useAuthList';
import useCustomError from '@/components/Signin/auth/useCustomError';
import Language from '@/components/Signin/auth/useLanguage';
import usePassword from '@/components/Signin/auth/usePassword';
import useProtocol from '@/components/Signin/auth/useProtocol';
import useSms from '@/components/Signin/auth/useSms';
import useSessionStore from '@/stores/session';
import { LoginType } from '@/types';
import {
Box,
Button,
Flex,
Img,
Tab,
TabIndicator,
TabList,
Tabs,
Image,
useDisclosure
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { debounce } from 'lodash';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import sealosTitle from 'public/images/sealos-title.png';
import { useEffect, useMemo, useState } from 'react';
import Background from './background';
export default function SigninComponent() {
const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv);
const {
service_protocol = '',
private_protocol = '',
needPassword = false,
needSms = false
} = platformEnv || {};
const needTabs = needPassword && needSms;
const disclosure = useDisclosure();
const { t, i18n } = useTranslation();
const [tabIndex, setTabIndex] = useState<LoginType>(LoginType.NONE);
const { ErrorComponent, showError } = useCustomError();
const { Protocol, isAgree, setIsInvalid } = useProtocol({ service_protocol, private_protocol });
const { SmsModal, login: smsSubmit, isLoading: smsLoading } = useSms({ showError });
const {
PasswordComponent,
pageState,
login: passwordSubmit,
isLoading: passwordLoading
} = usePassword({ showError });
const isLoading = useMemo(() => passwordLoading || smsLoading, [passwordLoading, smsLoading]);
// const isSignIn = useSessionStore((s) => s.isUserLogin);
// const router = useRouter();
// useEffect(() => {
// if (isSignIn()) {
// router.push('/pricing');
// }
// }, [isSignIn, router]);
const { AuthList } = useAuthList();
const loginConfig = useMemo(() => {
return {
[LoginType.SMS]: {
login: smsSubmit,
component: <SmsModal />
},
[LoginType.PASSWORD]: {
login: passwordSubmit,
component: <PasswordComponent />
},
[LoginType.NONE]: null
};
}, [PasswordComponent, SmsModal, passwordSubmit, smsSubmit]);
useEffect(() => {
setTabIndex(needSms ? LoginType.SMS : needPassword ? LoginType.PASSWORD : LoginType.NONE);
}, [needPassword, needSms]);
const LoginComponent = useMemo(
() => (tabIndex !== LoginType.NONE ? loginConfig[tabIndex].component : null),
[loginConfig, tabIndex]
);
const handleLogin = debounce(() => {
const selectedConfig = loginConfig[tabIndex];
if (isAgree && selectedConfig) {
const { login } = selectedConfig;
login();
uploadConvertData([3])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
} else {
setIsInvalid(true);
showError(t('Read and agree'));
}
}, 500);
return (
<Box
position={'relative'}
overflow={'hidden'}
w="100vw"
h="100vh"
// backgroundImage={'url(/images/bg.svg)'}
// backgroundRepeat={'no-repeat'}
// backgroundSize={'cover'}
// backgroundAttachment={'fixed'}
// min-width="100%"
// /min-height="100%"
// top={0}
// left={0}
// right={0}
// bottom={0}
>
<Background />
<Head>
<title>Sealos Cloud</title>
<meta name="description" content="sealos cloud dashboard" />
</Head>
<Flex h="full" w="full" flexDir={'column'} justifyContent={'center'} alignItems={'center'}>
<Box mb="36px">
<Img src={sealosTitle.src} w="135px"></Img>
</Box>
<Flex
p="30px 48px"
background={'rgba(255, 255, 255, 0.20)'}
boxShadow="0px 15px 20px 0px rgba(0, 0, 0, 0.20)"
backdropFilter="blur(100px)"
borderRadius="12px"
flexDir="column"
justifyContent="center"
align="center"
position={'relative'}
>
<ErrorComponent />
{pageState === 0 && needTabs && (
<Tabs
index={tabIndex}
onChange={(idx) => setTabIndex(idx)}
variant="unstyled"
p={'0'}
width={'full'}
>
<TabList
borderBottom={'2px solid rgba(255, 255, 255, 0.3)'}
p={'0'}
fontSize={'16px'}
fontWeight={'500'}
fontFamily={'PingFang SC'}
color={'rgba(255, 255, 255, 0.3)'}
gap={'20px'}
>
<Tab px="0" _selected={{ color: 'white' }}>
{t('Verification Code Login')}
</Tab>
<Tab px="0" _selected={{ color: 'white' }}>
{t('Password Login')}
</Tab>
</TabList>
<TabIndicator mt="-2px" height="2px" bg="#FFFFFF" borderRadius="1px" />
</Tabs>
)}
{LoginComponent}
<Protocol />
<Button
variant={'unstyled'}
background="linear-gradient(90deg, #000000 0%, rgba(36, 40, 44, 0.9) 98.29%)"
boxShadow="0px 4px 4px rgba(0, 0, 0, 0.25)"
color="#fff"
display={'flex'}
justifyContent={'center'}
alignItems={'center'}
type="submit"
_hover={{
opacity: '0.85'
}}
width="266px"
minH="42px"
mb="14px"
borderRadius="4px"
p="10px"
onClick={handleLogin}
>
{isLoading ? (t('Loading') || 'Loading') + '...' : t('Log In') || 'Log In'}
</Button>
<AuthList />
</Flex>
</Flex>
<Language disclosure={disclosure} i18n={i18n} />
</Box>
);
}
+45
View File
@@ -1,3 +1,5 @@
import { ClusterFormType } from '@/types';
export type ServiceItem = { label: string; icon: 'check' | 'star' };
const baseServiceItem: ServiceItem[] = [
@@ -55,3 +57,46 @@ export const contect: ServiceItem[] = [
{ label: '周一到周日,24h 内响应', icon: 'check' },
...baseServiceItem
];
export const CpuSlideMarkList = [
{ label: 8, value: 8 },
{ label: 256, value: 256 },
{ label: 512, value: 512 },
{ label: 1024, value: 1024 },
{ label: 2048, value: 2048 }
];
export const MemorySlideMarkList = [
{ label: 16, value: 16 },
{ label: 512, value: 512 },
{ label: 1024, value: 1024 },
{ label: 2048, value: 2048 },
{ label: 4096, value: 4096 }
];
export const MonthMapList = [
// unit month
{ label: '3个月', value: '3' },
{ label: '6个月', value: '6' },
{ label: '1年', value: '12' },
{ label: '2年', value: '24' },
{ label: '3年', value: '36' }
];
export const defaulClustertForm: ClusterFormType = {
cpu: 8,
memory: 16,
months: '3',
name: ''
};
export const freeClusterForm: ClusterFormType = {
cpu: 8,
memory: 16,
months: '3',
name: ''
};
export const cpuPriceMonth = 6; // ¥
export const memoryPriceMonth = 3; // ¥
@@ -1,15 +1,15 @@
import { freeClusterForm } from '@/constant/product';
import { authSession } from '@/services/backend/auth';
import {
findClusterByUIDAndClusterID,
isKubeSystemIDBound,
updateCluster,
updateClusterIdAndIssueLicense
} from '@/services/backend/db/cluster';
import { generateLicenseToken, hasIssuedLicense } from '@/services/backend/db/license';
import { ExpiredTime, generateLicenseToken, hasIssuedLicense } from '@/services/backend/db/license';
import { getPaymentByID } from '@/services/backend/db/payment';
import { jsonRes } from '@/services/backend/response';
import { LicenseRecordPayload } from '@/types';
import { result } from 'lodash';
import { calculatePrice } from '@/utils/tools';
import type { NextApiRequest, NextApiResponse } from 'next';
export type ActiveClusterParams = {
@@ -32,7 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (isBound) {
return jsonRes(res, {
code: 400,
message: 'clusterID is already bound to another cluster'
message: 'ID is already bound to another cluster'
});
}
@@ -41,8 +41,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
clusterId: clusterId
});
console.log(existingCluster, 'existingCluster');
if (existingCluster?.kubeSystemID) {
return jsonRes(res, {
code: 400,
@@ -50,29 +48,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
const handleEnterpriseAndLicense = async () => {
const handleScaledStandard = async () => {
if (!existingCluster?.orderID) return;
const payment = await getPaymentByID({
uid: userInfo.uid,
orderID: existingCluster.orderID
});
if (!payment) {
return jsonRes(res, { code: 400, message: 'No order found' });
return { message: 'No order found' };
}
const issuedLicense = await hasIssuedLicense({
uid: userInfo.uid,
orderID: existingCluster.orderID
});
if (issuedLicense) {
return jsonRes(res, { code: 400, message: 'orderID cannot be reused' });
return { message: 'orderID cannot be reused' };
}
const _token = generateLicenseToken({
type: 'Account',
clusterID: kubeSystemID,
data: { amount: payment.amount }
});
const expiredTime = parseInt(existingCluster.months || freeClusterForm.months) * ExpiredTime;
const _token = generateLicenseToken(
{
type: 'Cluster',
clusterID: kubeSystemID,
data: {
nodeCount: 99,
totalCPU: existingCluster.cpu || freeClusterForm.cpu,
totalMemory: existingCluster.memory || freeClusterForm.memory
}
},
expiredTime
);
const record: LicenseRecordPayload = {
uid: userInfo.uid,
@@ -81,8 +86,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
orderID: existingCluster.orderID,
quota: payment.amount,
payMethod: payment.payMethod,
type: 'Account',
clusterId: clusterId
type: 'Cluster',
clusterId: clusterId,
cpu: existingCluster.cpu,
memory: existingCluster.memory,
months: existingCluster.months,
expiredTime
};
const result = await updateClusterIdAndIssueLicense({
@@ -95,52 +104,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return result;
};
const handleStandardAndLicense = async () => {
const _token = generateLicenseToken({
type: 'Account',
clusterID: kubeSystemID,
data: { amount: 299 }
});
const handleStandard = async () => {
const expiredTime = parseInt(freeClusterForm.months) * ExpiredTime;
const _token = generateLicenseToken(
{
type: 'Cluster',
clusterID: kubeSystemID,
data: {
nodeCount: 99,
totalCPU: freeClusterForm.cpu,
totalMemory: freeClusterForm.memory
}
},
expiredTime
);
const record: LicenseRecordPayload = {
uid: userInfo.uid,
amount: 299,
amount: 0,
token: _token,
orderID: '',
quota: 299,
quota: 0,
payMethod: 'stripe',
type: 'Account',
clusterId: clusterId
type: 'Cluster',
clusterId: clusterId,
cpu: freeClusterForm.cpu,
memory: freeClusterForm.memory,
months: freeClusterForm.months,
expiredTime
};
const result = await updateClusterIdAndIssueLicense({
uid: userInfo.uid,
clusterId: clusterId,
kubeSystemID: kubeSystemID,
licensePayload: record
});
return result;
};
const handleActive = async () => {
return await updateCluster({
uid: userInfo.uid,
clusterId: clusterId,
updates: {
kubeSystemID: kubeSystemID,
kubeSystemUpdateAt: new Date()
}
});
return result;
};
let activeResult;
if (existingCluster?.orderID === null && existingCluster?.licenseID === null) {
activeResult = await handleActive();
} else if (existingCluster?.orderID === null && !existingCluster?.licenseID) {
activeResult = await handleStandardAndLicense();
} else if (existingCluster?.orderID !== null && !existingCluster?.licenseID) {
activeResult = await handleEnterpriseAndLicense();
if (existingCluster?.orderID) {
activeResult = await handleScaledStandard();
} else {
activeResult = await handleActive();
console.log('其他情况');
activeResult = await handleStandard();
}
return jsonRes(res, {
+25 -22
View File
@@ -1,20 +1,29 @@
import { freeClusterForm } from '@/constant/product';
import { authSession } from '@/services/backend/auth';
import { createClusterRecord } from '@/services/backend/db/cluster';
import { getPaymentByID } from '@/services/backend/db/payment';
import { jsonRes } from '@/services/backend/response';
import { ClusterType, CreateClusterParams, PaymentStatus } from '@/types';
import { calculatePrice } from '@/utils/tools';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { type = ClusterType.Standard, orderID } = req.body as CreateClusterParams;
const {
type = ClusterType.Standard,
orderID,
cpu,
memory,
name,
months
} = req.body as CreateClusterParams;
const userInfo = await authSession(req.headers);
if (!userInfo) {
return jsonRes(res, { code: 401, message: 'token verify error' });
}
if (type === ClusterType.Enterprise && orderID) {
if (orderID) {
const payment = await getPaymentByID({ uid: userInfo.uid, orderID: orderID });
if (!payment) {
return jsonRes(res, { code: 400, message: 'No order found' });
@@ -25,30 +34,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: 'Unpaid'
});
}
const result = await createClusterRecord({
uid: userInfo.uid,
type: type,
orderID: orderID
});
return jsonRes(res, {
data: result
});
const price = calculatePrice({ cpu, months, memory }, freeClusterForm);
if (payment.amount !== price) {
return jsonRes(res, { code: 400, message: 'mismatch between order and cluster size' });
}
}
if (type === ClusterType.Standard) {
const result = await createClusterRecord({
uid: userInfo.uid,
type: type
});
return jsonRes(res, {
data: result
});
}
const result = await createClusterRecord({
uid: userInfo.uid,
type: type,
orderID: orderID,
cpu,
memory,
months,
name
});
return jsonRes(res, {
code: 400,
message: 'Request parameter error'
data: result
});
} catch (error) {
jsonRes(res, { code: 500, data: error });
+68 -17
View File
@@ -1,6 +1,8 @@
import { freeClusterForm } from '@/constant/product';
import { authSession } from '@/services/backend/auth';
import { findClusterByUIDAndClusterID } from '@/services/backend/db/cluster';
import {
ExpiredTime,
createLicenseRecord,
generateLicenseToken,
hasIssuedLicense
@@ -8,27 +10,21 @@ import {
import { getPaymentByID } from '@/services/backend/db/payment';
import { jsonRes } from '@/services/backend/response';
import { CreateLicenseParams, LicenseRecordPayload, PaymentStatus } from '@/types';
import { calculatePrice } from '@/utils/tools';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { orderID, clusterId } = req.body as CreateLicenseParams;
const { orderID, clusterId, cpu, memory, months } = req.body as CreateLicenseParams;
const userInfo = await authSession(req.headers);
if (!userInfo) {
return jsonRes(res, { code: 401, message: 'token verify error' });
}
if (!orderID || !clusterId) {
if (!clusterId || !cpu || !memory || !months) {
return jsonRes(res, { code: 400, message: 'Request parameter error' });
}
const payment = await getPaymentByID({ uid: userInfo.uid, orderID: orderID });
if (!payment) {
return jsonRes(res, { code: 400, message: 'No order found' });
}
const issuedLicense = await hasIssuedLicense({ uid: userInfo.uid, orderID: orderID });
if (issuedLicense) {
return jsonRes(res, { code: 400, message: 'orderID cannot be reused' });
}
const cluster = await findClusterByUIDAndClusterID({
uid: userInfo.uid,
clusterId: clusterId
@@ -40,11 +36,62 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
const _token = generateLicenseToken({
type: 'Account',
clusterID: cluster.kubeSystemID,
data: { amount: payment.amount }
});
const expiredTime = parseInt(months) * ExpiredTime;
if (!orderID) {
const freeToken = generateLicenseToken(
{
type: 'Cluster',
clusterID: cluster.kubeSystemID,
data: {
totalCPU: freeClusterForm.cpu,
totalMemory: freeClusterForm.memory,
nodeCount: 99
}
},
expiredTime
);
const freeRecord: LicenseRecordPayload = {
uid: userInfo.uid,
amount: 0,
token: freeToken,
orderID: '',
quota: 0,
payMethod: 'stripe',
type: 'Cluster',
clusterId,
cpu: freeClusterForm.cpu,
memory: freeClusterForm.memory,
months: freeClusterForm.months,
expiredTime
};
const freeResult = await createLicenseRecord(freeRecord);
return jsonRes(res, { data: freeResult });
}
const payment = await getPaymentByID({ uid: userInfo.uid, orderID: orderID });
if (!payment) {
return jsonRes(res, { code: 400, message: 'No order found' });
}
const price = calculatePrice({ cpu, months, memory }, freeClusterForm);
if (payment.amount !== price) {
return jsonRes(res, { code: 400, message: 'mismatch between order and cluster size' });
}
const issuedLicense = await hasIssuedLicense({ uid: userInfo.uid, orderID: orderID });
if (issuedLicense) {
return jsonRes(res, { code: 400, message: 'orderID cannot be reused' });
}
const _token = generateLicenseToken(
{
type: 'Cluster',
clusterID: cluster.kubeSystemID,
data: { totalCPU: cpu, totalMemory: memory, nodeCount: 99 }
},
expiredTime
);
const record: LicenseRecordPayload = {
uid: userInfo.uid,
@@ -53,8 +100,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
orderID: orderID,
quota: payment.amount,
payMethod: payment.payMethod,
type: 'Account',
clusterId: clusterId
type: 'Cluster',
clusterId: clusterId,
cpu: cpu,
memory: memory,
months: months,
expiredTime
};
if (payment.status !== PaymentStatus.PaymentSuccess) {
@@ -39,7 +39,6 @@ export default function TutorialPanel() {
kubeSystemID: id
});
}
console.log('active cluster', result);
setClusterDetail(result);
queryClient.invalidateQueries({ queryKey: ['getClusterList'] });
queryClient.invalidateQueries({ queryKey: ['getLicenseByClusterId'] });
@@ -100,7 +100,6 @@ export default function CommandForm({
() =>
debounce((data: CommandFormType) => {
try {
console.log(2, data);
generateBashScript({ data, basePath, cloudVersion, enterprise });
} catch (error) {
console.log(error);
@@ -1,15 +1,15 @@
import { DownloadIcon, EmptyIcon, LicenseIcon, TokenIcon } from '@/components/Icon';
import { Box, Button, Flex, Text } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import RechargeComponent from './Recharge';
import { useQuery } from '@tanstack/react-query';
import { getLicenseByClusterId } from '@/api/license';
import CurrencySymbol from '@/components/CurrencySymbol';
import { DownloadIcon, EmptyIcon, LicenseIcon, TokenIcon } from '@/components/Icon';
import RechargeComponent from '@/components/recharge';
import useClusterDetail from '@/stores/cluster';
import { download } from '@/utils/downloadFIle';
import { json2License } from '@/utils/json2Yaml';
import CurrencySymbol from '@/components/CurrencySymbol';
import { getRemainingTime } from '@/utils/tools';
import useClusterDetail from '@/stores/cluster';
import { Box, Button, Flex, Text, useToast } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
export default function License() {
const { t } = useTranslation();
@@ -17,6 +17,7 @@ export default function License() {
const [pageSize, setPageSize] = useState(10);
const rechargeRef = useRef<{ onOpen: () => void }>();
const { clusterDetail } = useClusterDetail();
const toast = useToast();
const { data } = useQuery(
['getLicenseByClusterId', page, pageSize, clusterDetail?.clusterId],
@@ -35,7 +36,7 @@ export default function License() {
);
const downloadToken = (token: string) => {
download('license.yaml', json2License({ token: token, type: 'Account' }));
download('license.yaml', json2License({ token: token, type: 'Cluster' }));
};
return (
@@ -56,6 +57,16 @@ export default function License() {
w="140px"
variant={'black'}
onClick={() => {
if (!clusterDetail?.cpu && !clusterDetail?.memory) {
return toast({
title: '注意:旧版本不再支持续费,请获取最新版本的集群',
description:
'如果您需要续费,请先升级到最新版本的集群。遇到其他问题,如需帮助,可以通过工单及时联系我们。',
position: 'top',
isClosable: true,
status: 'warning'
});
}
rechargeRef.current?.onOpen();
}}
>
@@ -64,7 +75,7 @@ export default function License() {
</Flex>
{data?.records && data?.records?.length > 0 ? (
<Box w="100%" flex={1} overflowY={'auto'}>
<Box w="100%" flex={1} overflowY={'auto'} mb={'16px'}>
{data?.records.map((item) => (
<Flex
key={item?._id?.toString() ?? item.iat}
@@ -77,19 +88,30 @@ export default function License() {
mt="16px"
>
<Box w="100%">
<Flex alignItems={'center'} mb="8px">
<Flex alignItems={'center'}>
<LicenseIcon />
<Text color={'#485058'} fontSize={'16px'} fontWeight={500} mx="10px">
License
</Text>
<CurrencySymbol w="14px" h="14px" type={'shellCoin'} />
<Text color={'#5A646E'} fontSize={'12px'} fontWeight={500}>
{item.amount}
{item?.amount}
</Text>
</Flex>
<Text color={'#5A646E'} fontSize={'12px'} fontWeight={500}>
{t('Remaining Time')} {getRemainingTime(item.exp)}
</Text>
{item?.cpu && (
<Flex
mt={'8px'}
color={'#5A646E'}
fontSize={'12px'}
fontWeight={500}
alignItems={'center'}
gap={'16px'}
>
<Text>CPU: {item?.cpu} Core</Text>
<Text>内存: {item?.memory} G</Text>
<Text>有效时间: {getRemainingTime(item.exp)}</Text>
</Flex>
)}
</Box>
<Box h="100%" w="1px" border={'1px solid #EFF0F1'}></Box>
<Flex justifyContent={'center'} alignItems={'center'} ml="auto" w="160px">
@@ -122,7 +144,7 @@ export default function License() {
</Flex>
)}
</Flex>
<RechargeComponent ref={rechargeRef} />
<RechargeComponent ref={rechargeRef} isLicensePay={true} key={'license'} />
</Box>
);
}
@@ -63,7 +63,7 @@ export default function ClusterRecord() {
cursor={'pointer'}
>
<Flex w="100%" flexDirection={'column'}>
<Flex>
<Flex alignItems={'center'}>
<Text
color={'#485058'}
fontSize={'16px'}
@@ -77,25 +77,24 @@ export default function ClusterRecord() {
>
{item?.displayName ? item.displayName : `${t('Cluster')}`}
</Text>
<Center
bg={
item.type === ClusterType.Standard
? 'rgba(0, 169, 166, 0.10)'
: 'rgba(33, 155, 244, 0.10)'
}
color={item.type === ClusterType.Standard ? '#00A9A6' : 'brightBlue.500'}
borderRadius="4px"
p="3px 8px"
fontSize={'12px'}
fontWeight={500}
>
{t(item.type)}
</Center>
<Text color={'#7B838B'} fontSize={'11px'} fontWeight={400}>
集群ID: {item?.kubeSystemID ? item?.kubeSystemID : '未绑定'}
</Text>
</Flex>
<Text mt="8px" color={'#7B838B'} fontSize={'11px'} fontWeight={400}>
集群ID: {item?.kubeSystemID ? item?.kubeSystemID : '未绑定'}
{/* {formatTime(item.createdAt, 'YYYY-MM-DD HH:mm')} */}
</Text>
{item?.cpu && (
<Flex
alignItems={'center'}
fontSize={'10px'}
fontWeight={500}
color={'#667085'}
gap={'16px'}
mt={'9px'}
>
<Text>CPU: {item?.cpu} Core</Text>
<Text>内存: {item?.memory} G</Text>
</Flex>
)}
</Flex>
<Flex justifyContent={'center'} alignItems={'center'} ml="auto" flexShrink={0}>
{item?.kubeSystemID ? (
@@ -1,382 +0,0 @@
import { checkWechatPay, createPayment, handlePaymentResult } from '@/api/payment';
import { getSystemEnv, uploadConvertData } from '@/api/system';
import { LeftIcon, StripeIcon, WechatIcon } from '@/components/Icon';
import useBonusBox from '@/hooks/useBonusBox';
import { PaymentStatus, TPayMethod, WechatPaymentData } from '@/types';
import { deFormatMoney } from '@/utils/tools';
import {
Button,
Checkbox,
Flex,
Link,
Modal,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Text,
useDisclosure,
useToast
} from '@chakra-ui/react';
import { loadStripe } from '@stripe/stripe-js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import WechatPayment from '@/components/WechatPayment';
import { createLicense } from '@/api/license';
import usePaymentDataStore from '@/stores/payment';
import useClusterDetail from '@/stores/cluster';
import { findClusterById } from '@/api/cluster';
export default forwardRef(function RechargeComponent(props, ref) {
const { clusterDetail, setClusterDetail } = useClusterDetail();
const router = useRouter();
const queryClient = useQueryClient();
const toast = useToast({ position: 'top', duration: 2000 });
const { t } = useTranslation();
const [isAgree, setIsAgree] = useState(false);
const [isInvalid, setIsInvalid] = useState(false);
const { BonusBox, selectAmount } = useBonusBox();
const { isOpen, onOpen, onClose } = useDisclosure();
// 整个流程跑通需要状态管理, 0 初始态, 1 创建支付单, 2 支付中, 3 支付成功
const [complete, setComplete] = useState<0 | 1 | 2 | 3>(0);
const [payType, setPayType] = useState<TPayMethod>('wechat');
const [detail, setDetail] = useState(false);
const [orderID, setOrderID] = useState('');
const [wechatPaymentData, setWechatPaymentData] = useState<WechatPaymentData>();
const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv);
// 用于避免微信支付,窗口关闭后感知不到的问题
const { paymentData, setPaymentData, deletePaymentData, isExpired } = usePaymentDataStore();
const onClosePayment = useCallback(() => {
setOrderID('');
setComplete(0);
setDetail(false);
onClose();
}, [onClose]);
useImperativeHandle(
ref,
() => {
return {
onOpen() {
onOpen();
}
};
},
[onOpen]
);
const handlePayConfirm = (type: TPayMethod) => {
setPayType(type);
if (!isAgree) {
return toast({
status: 'error',
title: t('Please read and agree to the agreement'),
isClosable: true,
position: 'top'
});
}
if (type === 'stripe' && selectAmount < 10) {
return toast({
status: 'error',
title: t('Pay Minimum Tips')
});
}
setDetail(true);
setComplete(1);
paymentMutation.mutate();
};
const paymentMutation = useMutation(
() =>
createPayment({
amount: deFormatMoney(selectAmount).toString(),
payMethod: payType,
currency: 'CNY',
stripeSuccessCallBackUrl: `/cluster?clusterId=${clusterDetail?.clusterId}&tab=license&stripeState=success`,
stripeErrorCallBackUrl: `/cluster?clusterId=${clusterDetail?.clusterId}&tab=license&stripeState=error`
}),
{
async onSuccess(data) {
if (payType === 'stripe' && platformEnv && data?.sessionID) {
const stripe = await loadStripe(platformEnv?.stripePub);
stripe?.redirectToCheckout({
sessionId: data.sessionID
});
}
if (payType === 'wechat' && data?.tradeNO && data?.codeURL) {
setOrderID(data.orderID);
setWechatPaymentData({ tradeNO: data?.tradeNO, codeURL: data?.codeURL });
setComplete(2);
setPaymentData(data.orderID);
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
}
);
const licenseMutation = useMutation(
({ orderID, clusterId }: { orderID: string; clusterId: string }) =>
createLicense({ orderID, clusterId }),
{
onSuccess(data) {
onClosePayment();
toast({
status: 'success',
title: t('License issued successfully'),
isClosable: true,
position: 'top'
});
queryClient.invalidateQueries(['getLicenseByClusterId']);
deletePaymentData();
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
}
);
useQuery(['getPaymentResult', orderID], () => handlePaymentResult({ orderID }), {
refetchInterval: complete === 2 ? 3 * 1000 : false,
enabled: complete === 2 && !!orderID,
cacheTime: 0,
staleTime: 0,
onSuccess(data) {
console.log(data, clusterDetail, 1111111111111);
if (!clusterDetail?.clusterId) {
return toast({
position: 'top',
status: 'error',
title: '请联系管理员'
});
}
if (data.status === PaymentStatus.PaymentSuccess) {
licenseMutation.mutate({ orderID: data.orderID, clusterId: clusterDetail.clusterId });
uploadConvertData([90])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
});
// checkWechatPay;
useQuery(['checkWechatPay'], () => checkWechatPay('license'), {
enabled: !isExpired() && !!paymentData?.orderId,
onSuccess(data) {
console.log(data, 'Handle wechat shutdown situation');
if (data.status === PaymentStatus.PaymentSuccess) {
if (!clusterDetail?.clusterId) {
return toast({
position: 'top',
status: 'error',
title: '请联系管理员'
});
}
licenseMutation.mutate({ orderID: data.orderID, clusterId: clusterDetail.clusterId });
}
}
});
const getCluster = async (id: string) => {
const cluster = await findClusterById({
clusterId: id
});
setClusterDetail(cluster);
};
useEffect(() => {
const { stripeState, orderID, clusterId, tab } = router.query;
console.log(stripeState, orderID, clusterId);
const clearQuery = () => {
router.replace({
pathname: '/cluster',
query: { tab }
});
};
if (stripeState === 'success') {
clusterId ? getCluster(clusterId as string) : '';
setComplete(2);
setOrderID(orderID as string);
setTimeout(clearQuery, 0);
} else if (stripeState === 'error') {
clusterId ? getCluster(clusterId as string) : '';
toast({
status: 'error',
duration: 3000,
title: t('Stripe Cancel'),
isClosable: true,
position: 'top'
});
onClosePayment();
setTimeout(clearQuery, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Modal isOpen={isOpen} onClose={onClosePayment}>
<ModalOverlay />
<ModalContent
borderRadius={'12px'}
maxW="502px"
minH={'495px'}
display={'flex'}
flexDirection={'column'}
>
<ModalCloseButton />
{detail ? (
<>
<ModalHeader
cursor={'pointer'}
pt={'24px'}
py={'20px'}
onClick={() => {
setOrderID('');
setComplete(0);
setDetail(false);
}}
display={'flex'}
alignItems={'center'}
>
<LeftIcon />
<Text ml="8px" color={'#24282C'}>
{t('Purchase License')}
</Text>
</ModalHeader>
{complete === 1 ? (
<Flex flex={1} alignItems={'center'} justifyContent={'center'}>
<Spinner size={'xl'} />
</Flex>
) : (
<WechatPayment
complete={complete}
codeURL={wechatPaymentData?.codeURL}
tradeNO={wechatPaymentData?.tradeNO}
/>
)}
</>
) : (
<>
<ModalHeader py={'20px'}>{t('Purchase License')}</ModalHeader>
<Flex flex={1} flexDirection="column" alignItems="center" px={'24px'} pb="45px">
<Text alignSelf={'start'} color="#7B838B" fontWeight={'normal'} mb={'16px'}>
{t('Select Amount')}
</Text>
<BonusBox />
<Flex alignSelf={'flex-start'} align={'center'} mt={'32px'}>
<Checkbox
isInvalid={isInvalid}
mr={'8px'}
isChecked={isAgree}
variant={'unstyled'}
onChange={(e) => {
setIsInvalid(false);
setIsAgree(e.target.checked);
}}
/>
<Text
fontStyle="normal"
fontWeight="400"
fontSize="12px"
lineHeight="140%"
color={'#1D8CDC'}
>
{t('agree policy')}
<Link
isExternal
href={platformEnv?.service_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Service Agreement')}
</Link>
{t('and')}
<Link
isExternal
href={platformEnv?.private_protocol}
_hover={{
color: 'rgba(94, 189, 242, 1)',
borderBottom: '1px solid rgba(94, 189, 242, 1)'
}}
px="4px"
>
{t('Privacy Policy')}
</Link>
</Text>
{/* <OuterLink text={t('View Discount Rules')}></OuterLink> */}
</Flex>
<Flex gap={'16px'} width={'full'} mt="20px">
{platformEnv?.stripeEnabled && (
<Button
size="primary"
variant="primary"
mt="20px"
w="full"
h="auto"
py="14px"
px="34px"
onClick={() => handlePayConfirm('stripe')}
>
<StripeIcon />
<Text ml="12px">{t('pay with stripe')}</Text>
</Button>
)}
{platformEnv?.wechatEnabledRecharge && (
<Button
size="primary"
variant="primary"
mt="20px"
w="full"
h="auto"
py="14px"
px="34px"
onClick={() => handlePayConfirm('wechat')}
>
<WechatIcon fill={'#33BABB'} />
<Text ml="12px">{t('pay with wechat')}</Text>
</Button>
)}
</Flex>
</Flex>
</>
)}
</ModalContent>
</Modal>
);
});
@@ -109,7 +109,7 @@ export default function Tutorial({ ossFileUrl, customBasePatch }: TutorialProps)
}),
{
async onSuccess(data) {
if (data?.orderID && data.type === ClusterType.Enterprise) {
if (data?.orderID && data.type !== ClusterType.Standard) {
const _link = await getFileByName(ossFileUrl);
setOssLink(_link);
}
@@ -180,7 +180,7 @@ export default function Tutorial({ ossFileUrl, customBasePatch }: TutorialProps)
</AccordionPanel>
</AccordionItem>
{clusterDetail?.type === ClusterType.Enterprise && (
{clusterDetail?.type !== ClusterType.Standard && (
<AccordionItem
bg="#fff"
border={'1px solid #E8EBF0'}
+8 -8
View File
@@ -7,7 +7,7 @@ import {
import { EmptyIcon } from '@/components/Icon';
import Layout from '@/components/Layout';
import useClusterDetail from '@/stores/cluster';
import { ClusterResult } from '@/types';
import { ClusterDB } from '@/types';
import { compareFirstLanguages } from '@/utils/tools';
import {
Button,
@@ -35,7 +35,7 @@ import Tutorial, { TutorialProps } from './components/Tutorial';
// Cluster recharge callback clusterId
export default function MyCluster({ ossFileUrl, customBasePatch }: TutorialProps) {
const { t } = useTranslation();
const { clusterDetail, setClusterDetail, clearClusterDetail } = useClusterDetail();
const { clusterDetail, setClusterDetail } = useClusterDetail();
const [isLargerThanLG] = useMediaQuery(['(min-width: 992px)', '(display-mode: browser)']);
const router = useRouter();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -56,7 +56,7 @@ export default function MyCluster({ ossFileUrl, customBasePatch }: TutorialProps
enabled: !clusterDetail?.clusterId && !router.query?.clusterId,
onSuccess(data) {
console.log(data);
if (!clusterDetail?.clusterId && !router.query?.clusterIds) {
if (!clusterDetail?.clusterId && !router.query?.clusterId) {
console.log(data?.records?.[0], 'init cluster list');
setClusterDetail(data?.records?.[0]);
}
@@ -100,7 +100,7 @@ export default function MyCluster({ ossFileUrl, customBasePatch }: TutorialProps
}
};
function findStandardRecordWithoutClusterId(records: ClusterResult[], type: string) {
function findStandardRecordWithoutClusterId(records: ClusterDB[], type: string) {
records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
for (const record of records) {
if (record.type === type && !record?.kubeSystemID) {
@@ -115,18 +115,18 @@ export default function MyCluster({ ossFileUrl, customBasePatch }: TutorialProps
if (router.query?.systemId) {
const systemId = router.query.systemId as string;
isKubeSystemIDBound(systemId).then((res) => {
console.log(res);
if (!res.isBound) {
onOpen();
router.push({
pathname: '/pricing',
query: router.query
});
} else {
findClusterBySystemId({ systemId }).then((res) => {
console.log(res, 11);
setClusterDetail(res);
});
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isSuccess && data?.total === 0) {
@@ -23,7 +23,7 @@ export default function History() {
);
const downloadToken = (token: string) => {
download('license.yaml', json2License({ token: token, type: 'Account' }));
download('license.yaml', json2License({ token: token, type: 'Cluster' }));
};
return (
@@ -1,98 +1,42 @@
import { createCluster } from '@/api/cluster';
import { checkWechatPay, createPayment, handlePaymentResult } from '@/api/payment';
import { getSystemEnv, uploadConvertData } from '@/api/system';
import { StripeIcon, SuccessIcon } from '@/components/Icon';
import { company, contect, standard } from '@/constant/product';
import { useConfirm } from '@/hooks/useConfirm';
import usePaymentDataStore from '@/stores/payment';
import useRouteParamsStore from '@/stores/routeParams';
import useSessionStore from '@/stores/session';
import {
ClusterType,
CreateClusterParams,
PaymentStatus,
TPayMethod,
WechatPaymentData
} from '@/types';
import {
AbsoluteCenter,
Box,
Button,
Center,
Divider,
Flex,
Modal,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spinner,
Text,
useDisclosure,
useToast
} from '@chakra-ui/react';
import { loadStripe } from '@stripe/stripe-js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { QRCodeSVG } from 'qrcode.react';
import { useCallback, useEffect, useState } from 'react';
import BillingMeter from '@/components/billing-meter';
import RechargeComponent from '@/components/recharge';
import { company, contect, defaulClustertForm, freeClusterForm } from '@/constant/product';
import { ClusterFormType, ClusterType } from '@/types';
import { calculatePrice } from '@/utils/tools';
import { Button, Flex, Text } from '@chakra-ui/react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import ServicePackage from './ServicePackage';
import { useRouter } from 'next/router';
export default function Product() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [clusterType, setClusterType] = useState<ClusterType>(ClusterType.ScaledStandard);
const [price, setPrice] = useState(0);
const rechargeRef = useRef<{ onOpen: (form?: ClusterFormType) => void; isOpen: boolean }>();
const router = useRouter();
const { isOpen, onOpen, onClose } = useDisclosure();
const { openConfirm, ConfirmChild } = useConfirm();
const toast = useToast({ position: 'top', duration: 2000 });
// 整个流程跑通需要状态管理, 0 初始态, 1 创建支付单, 2 支付中, 3 支付成功
const [complete, setComplete] = useState<0 | 1 | 2 | 3>(0);
const [payType, setPayType] = useState<TPayMethod>('wechat');
const [clusterType, setClusterType] = useState<ClusterType>(ClusterType.Standard);
const [orderID, setOrderID] = useState('');
const [wechatData, setWechatData] = useState<WechatPaymentData>();
const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv);
const [remainingSeconds, setRemainingSeconds] = useState(1); // 初始值为2秒
const { data: routeParams, setRouteParams, clearRouteParams } = useRouteParamsStore();
const { isUserLogin } = useSessionStore();
// Used to detect missing WeChat payment results
const { paymentData, setPaymentData, deletePaymentData, isExpired } = usePaymentDataStore();
const onClosePayment = useCallback(() => {
setOrderID('');
setComplete(0);
onClose();
}, [onClose]);
const formHook = useForm<ClusterFormType>({
defaultValues: defaulClustertForm
});
const openPayModal = () => {
setComplete(1);
setPayType('wechat');
paymentMutation.mutate((599 * 100).toString());
onOpen();
};
formHook.watch((data) => {
if (!data) return;
const price = calculatePrice(data as ClusterFormType, freeClusterForm);
setPrice(price);
});
const handleStripePay = () => {
setComplete(1);
setPayType('stripe');
paymentMutation.mutate((599 * 100).toString());
};
useEffect(() => {
const price = calculatePrice(defaulClustertForm, freeClusterForm);
setPrice(price);
}, []);
const handleProductByType = (type: ClusterType) => {
setClusterType(type);
if (type === ClusterType.Standard) {
openConfirm(
() => {
clusterMutation.mutate({ type: ClusterType.Standard });
},
() => {
router.push('/cluster');
}
)();
}
if (type === ClusterType.Enterprise) {
openPayModal();
if (type === ClusterType.ScaledStandard) {
rechargeRef.current?.onOpen();
return;
}
if (type === ClusterType.Contact) {
window.open(
'https://fael3z0zfze.feishu.cn/share/base/form/shrcnesSfEK65JZaAf2W6Fwz6Ad',
@@ -101,167 +45,21 @@ export default function Product() {
}
};
const paymentMutation = useMutation(
(amount: string) =>
createPayment({
amount: amount,
payMethod: payType,
currency: 'CNY',
stripeSuccessCallBackUrl: '/pricing?stripeState=success',
stripeErrorCallBackUrl: '/pricing?stripeState=error'
}),
{
async onSuccess(data) {
if (payType === 'stripe' && platformEnv && data?.sessionID) {
const stripe = await loadStripe(platformEnv?.stripePub);
stripe?.redirectToCheckout({
sessionId: data.sessionID
});
}
if (payType === 'wechat' && data?.tradeNO && data?.codeURL) {
setOrderID(data.orderID);
setWechatData({ tradeNO: data?.tradeNO, codeURL: data?.codeURL });
setComplete(2);
setPaymentData(data.orderID);
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
}
);
// Create a standard cluster
const clusterMutation = useMutation((payload: CreateClusterParams) => createCluster(payload), {
onSuccess(data) {
console.log(data, 'clusterMutation');
setComplete(3);
queryClient.invalidateQueries(['getClusterList']);
deletePaymentData();
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
});
// Purchase Enterprise Edition Cluster
useQuery(['getPaymentResult', orderID], () => handlePaymentResult({ orderID }), {
refetchInterval: complete === 2 ? 3 * 1000 : false,
enabled: complete === 2 && !!orderID,
cacheTime: 0,
staleTime: 0,
onSuccess(data) {
if (data.status === PaymentStatus.PaymentSuccess) {
clusterMutation.mutate({ orderID: data.orderID, type: ClusterType.Enterprise });
uploadConvertData([90])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});
}
},
onError(err: any) {
toast({
status: 'error',
title: err?.message || '',
isClosable: true,
position: 'top'
});
setComplete(0);
}
});
// checkWechatPay
useQuery(['checkWechatPay'], () => checkWechatPay('cluster'), {
enabled: !isExpired() && !!paymentData?.orderId,
onSuccess(data) {
console.log(data, 'Handle wechat shutdown situation');
if (data.status === PaymentStatus.PaymentSuccess) {
clusterMutation.mutate({ orderID: data.orderID, type: ClusterType.Enterprise });
}
},
onError(err) {
console.log(err);
}
});
// handle stripe
useEffect(() => {
const { stripeState, orderID } = router.query;
const clearQuery = () => {
router.replace({
pathname: '/pricing',
query: null
});
};
if (stripeState === 'success') {
toast({
status: 'success',
title: t('Checking Payment Results'), // 这里改为license 签发成功
isClosable: true,
duration: 3500,
position: 'top'
});
setClusterType(ClusterType.Enterprise);
setComplete(2);
setOrderID(orderID as string);
setTimeout(clearQuery, 0);
} else if (stripeState === 'error') {
toast({
status: 'error',
duration: 3000,
title: t('Stripe Cancel'),
isClosable: true,
position: 'top'
});
onClosePayment();
setTimeout(clearQuery, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// handle success
useEffect(() => {
if (complete === 3) {
onOpen();
const timer = setInterval(() => {
if (remainingSeconds > 0) {
setRemainingSeconds(remainingSeconds - 1);
} else {
clearInterval(timer);
router.push('/cluster');
}
}, 1000);
return () => {
clearInterval(timer);
if (router.query?.systemId) {
const { systemId, nodeCount, totalCpu, totalMemory } = router.query as unknown as {
systemId: string;
nodeCount: number;
totalCpu: number;
totalMemory: number;
};
rechargeRef.current?.onOpen({
cpu: totalCpu,
memory: totalMemory,
months: '3',
systemId: systemId
});
}
}, [complete, onOpen, remainingSeconds, router]);
// handle Jump link
useEffect(() => {
const { clusterType, external } = routeParams;
console.log(clusterType, external, 'pricing');
if (clusterType && external) {
handleProductByType(routeParams.clusterType as ClusterType);
clearRouteParams();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -278,88 +76,35 @@ export default function Product() {
gap={'36px'}
px="24px"
>
<ServicePackage items={standard}>
<Text color="#00A9A6" fontSize="18px" fontWeight="600">
<ServicePackage items={company}>
<Text color="#0884DD" fontSize="18px" fontWeight="600">
标准版
</Text>
<Flex alignItems={'center'} mt="16px">
<Text color="#000" fontSize="40px" fontWeight="600">
¥0
</Text>
<Center
h="34px"
px="16px"
ml="24px"
borderRadius={'15px 15px 15px 0px'}
bgColor={'#EBF7FD'}
color={'#00A9A6'}
fontWeight={500}
fontSize={'20px'}
>
赠 ¥299
</Center>
</Flex>
<Text mt="16px" color={'#24282C'} fontSize={'18px'} fontWeight={600}>
适合开发者测试,或 POC demo
</Text>
<Button
w="100%"
mt="28px"
bgColor={'#F4F6F8'}
fontSize={'16px'}
color={'#24282C'}
fontWeight={600}
onClick={() => handleProductByType(ClusterType.Standard)}
>
一键安装
</Button>
</ServicePackage>
<ServicePackage items={company}>
<Text color="#36ADEF" fontSize="18px" fontWeight="600">
企业版
</Text>
<Flex alignItems={'center'} mt="16px">
<Text color="#000" fontSize="40px" fontWeight="600">
¥599
</Text>
<Center
h="34px"
px="16px"
ml="24px"
borderRadius={'15px 15px 15px 0px'}
bgColor={'#EBF7FD'}
color={'#36ADEF'}
fontWeight={500}
fontSize={'20px'}
>
赠 ¥599
</Center>
</Flex>
<Text mt="16px" color={'#24282C'} fontSize={'18px'} fontWeight={600}>
适合企业生产环境
<Text mt="24px" color={'#24282C'} fontSize={'24px'} fontWeight={600}>
适合开发者测试, POC demo,企业生产环境
</Text>
<Button
w="100%"
mt="28px"
bgColor={'#AFDEF9'}
fontSize={'16px'}
fontSize={'14px'}
color={'#24282C'}
fontWeight={600}
onClick={() => handleProductByType(ClusterType.Enterprise)}
fontWeight={500}
onClick={() => handleProductByType(ClusterType.ScaledStandard)}
>
购买
获取
</Button>
</ServicePackage>
<ServicePackage items={contect}>
<Text color="#00A9A6" fontSize="18px" fontWeight="600">
定制版
</Text>
<Text mt="32px" color={'#24282C'} fontSize={'24px'} fontWeight={600} w="200px">
<Text mt="24px" color={'#24282C'} fontSize={'24px'} fontWeight={600} w="200px">
适合大规模集群与大型企业客户
</Text>
<Button
w="100%"
mt="42px"
mt="32px"
bgColor={'#F4F6F8'}
fontSize={'16px'}
color={'#24282C'}
@@ -369,96 +114,9 @@ export default function Product() {
联系我们
</Button>
</ServicePackage>
<BillingMeter formHook={formHook} price={price} />
</Flex>
<Modal isOpen={isOpen} onClose={onClosePayment} closeOnOverlayClick={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
{complete !== 3 && <ModalCloseButton />}
{complete === 3 ? (
<Flex
flexDirection={'column'}
px="37px"
pt="100px"
pb="200px"
alignItems={'center'}
justifyContent={'center'}
>
<SuccessIcon />
<Text mt="24px" color={'#24282C'} fontSize={'18px'} fontWeight={600}>
{t('Cluster Pay Success', {
clusterType: t(clusterType)
})}
</Text>
<Text color={'#5A646E'} fontSize={'12px'} fontWeight={500} mt="16px">
{remainingSeconds} 后跳转至集群部署页
</Text>
</Flex>
) : (
<Flex
flexDirection="column"
px="37px"
py="30px"
justify={'center'}
align={'center'}
justifyContent={'center'}
alignItems={'center'}
position={'relative'}
>
{payType === 'wechat' ? (
<>
<Text fontSize={'20px'} fontWeight={500} color="#24282C" textAlign="center">
{t('Scan with WeChat')}
</Text>
<Box my="32px">
{complete === 2 && !!wechatData?.codeURL ? (
<QRCodeSVG
size={185}
value={wechatData?.codeURL}
style={{ margin: '0 auto' }}
imageSettings={{
// 二维码中间的logo图片
src: 'images/pay_wechat.svg',
height: 40,
width: 40,
excavate: true // 中间图片所在的位置是否镂空
}}
/>
) : (
<Spinner />
)}
</Box>
<Text color="#717D8A" fontSize="12px" fontWeight="normal">
{t('Order Number')}: {wechatData?.tradeNO || ''}
</Text>
</>
) : (
<Spinner />
)}
<Box mt="36px" position="relative" w="100%">
<Divider bg={'#E9EEF5'} />
<AbsoluteCenter bg="white" px="4" color={'#7B838B'} fontSize={'12px'}>
其他方式支付
</AbsoluteCenter>
</Box>
<Button
mt="26px"
size="primary"
variant="primary"
w="218px"
h="44px"
onClick={handleStripePay}
backgroundColor={'#24282C'}
>
<StripeIcon />
<Text ml="12px">{t('pay with stripe')}</Text>
</Button>
</Flex>
)}
</ModalContent>
</Modal>
<ConfirmChild />
<RechargeComponent ref={rechargeRef} isLicensePay={false} key={'cluster'} />
</>
);
}
@@ -17,7 +17,7 @@ export default function ServicePackage({ items, children }: ServicePackageProps)
px="48px"
pt="48px"
flexDirection={'column'}
maxW="400px"
w="400px"
minH={'676px'}
>
{children}
@@ -28,7 +28,7 @@ export default function ServicePackage({ items, children }: ServicePackageProps)
<Text
fontSize={'14px'}
fontWeight={500}
color={item.icon === 'check' ? '#485058' : 'brightBlue.500'}
color={item.icon === 'check' ? '#485058' : '#0884DD'}
ml="15px"
>
{item.label}
+1 -7
View File
@@ -1,4 +1,4 @@
import SigninComponent from '@/components/Signin';
import SigninComponent from '@/components/login-module';
import useRouteParamsStore from '@/stores/routeParams';
import useSessionStore from '@/stores/session';
import { ClusterType } from '@/types';
@@ -13,15 +13,9 @@ export default function SigninPage() {
const { isUserLogin } = useSessionStore();
useEffect(() => {
const { clusterType, external } = router.query;
console.log(clusterType, external, '--------');
if (external && clusterType) {
setRouteParams(external as string, clusterType as ClusterType);
}
if (isUserLogin()) {
router.push('/pricing');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// handle baidu id
@@ -1,7 +1,6 @@
import { ClusterDB, ClusterRecordPayload, ClusterType, LicenseRecordPayload } from '@/types';
import { ClusterDB, ClusterRecordPayload, LicenseRecordPayload } from '@/types';
import { v4 as uuid } from 'uuid';
import { connectToDatabase } from './mongodb';
import { getOssUrl } from './oss';
import { createLicenseRecord } from './license';
import { ObjectId } from 'mongodb';
@@ -15,16 +14,12 @@ async function connectClusterCollection() {
export async function createClusterRecord(payload: ClusterRecordPayload) {
const collection = await connectClusterCollection();
const now = new Date();
const expirationTime = new Date(now.getTime() + 30 * 60000); // 30 minutes in milliseconds
const tarUrl = await getOssUrl({ fileName: 'sealos-cloud-dev.tar.gz' });
const md5Url = await getOssUrl({ fileName: 'sealos-cloud-dev.tar.gz.md5' });
// const now = new Date();
// const expirationTime = new Date(now.getTime() + 30 * 60000); // 30 minutes in milliseconds
// const tarUrl = await getOssUrl({ fileName: 'sealos-cloud-dev.tar.gz' });
// const md5Url = await getOssUrl({ fileName: 'sealos-cloud-dev.tar.gz.md5' });
let orderID;
if (payload.type === ClusterType.Enterprise) {
orderID = payload.orderID;
}
let orderID = payload.orderID;
const record: ClusterDB = {
clusterId: uuid(),
@@ -32,11 +27,15 @@ export async function createClusterRecord(payload: ClusterRecordPayload) {
orderID: orderID, // order ID
type: payload.type,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
cpu: payload.cpu,
memory: payload.memory,
months: payload.months,
displayName: payload.name
};
const result = await collection.insertOne(record);
return result;
await collection.insertOne(record);
return record;
}
export async function getClusterRecordsByUid({
@@ -3,7 +3,7 @@ import { base64Decode } from '@/utils/tools';
import { sign } from 'jsonwebtoken';
import { connectToDatabase } from './mongodb';
const ExpiredTime = 30 * 24 * 60 * 60;
export const ExpiredTime = 30 * 24 * 60 * 60;
async function connectLicenseCollection() {
const client = await connectToDatabase();
@@ -19,7 +19,11 @@ export async function createLicenseRecord({
quota,
payMethod,
type,
clusterId
clusterId,
cpu,
memory,
months,
expiredTime = ExpiredTime
}: LicenseRecordPayload) {
const collection = await connectLicenseCollection();
@@ -34,12 +38,15 @@ export async function createLicenseRecord({
quota: quota
},
iat: now, // Store the current timestamp as iat
exp: now + ExpiredTime, // Set expiration to one day from now (in seconds)
exp: now + expiredTime, // Set expiration to one day from now (in seconds)
amount: amount,
type: type,
createdAt: new Date(),
updatedAt: new Date(),
clusterId: clusterId
clusterId: clusterId,
cpu,
memory,
months
};
const result = await collection.insertOne(record);
@@ -99,7 +106,7 @@ export async function getLicenseRecordsByUid({
return result;
}
export function generateLicenseToken(payload: LicenseToken, time = ExpiredTime) {
export function generateLicenseToken(payload: LicenseToken, time: number) {
const privateKey = process.env.LICENSE_PRIVATE_KEY;
if (!privateKey) {
throw new Error('LICENSE PRIVATE KEY IS MISSING');
+3 -3
View File
@@ -1,11 +1,11 @@
import { ClusterResult } from '@/types';
import { ClusterDB } from '@/types';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
type clusterDetailState = {
clusterDetail: ClusterResult | undefined;
setClusterDetail: (data: ClusterResult) => void;
clusterDetail: ClusterDB | undefined;
setClusterDetail: (data: ClusterDB) => void;
clearClusterDetail: () => void;
};
+19 -5
View File
@@ -6,8 +6,22 @@ type PaymentDataState = {
paymentData?: {
orderId: string;
expirationTime: number;
cpu: number;
memory: number;
months: string;
clusterId?: string;
name?: string;
systemId?: string;
};
setPaymentData: (orderId: string) => void;
setPaymentData: (data: {
orderId: string;
cpu: number;
memory: number;
months: string;
clusterId?: string;
name?: string;
systemId?: string;
}) => void;
deletePaymentData: () => void;
isExpired: () => boolean;
};
@@ -16,12 +30,12 @@ export const usePaymentDataStore = create(
persist(
immer<PaymentDataState>((set, get) => ({
paymentData: undefined,
setPaymentData: (id) => {
const currentTime = new Date().getTime(); // 获取当前时间戳
const expirationTime = currentTime + 5 * 60000; // 10 minutes in milliseconds
setPaymentData: (data) => {
const currentTime = new Date().getTime();
const expirationTime = currentTime + 5 * 60000;
set({
paymentData: {
orderId: id,
...data,
expirationTime: expirationTime
}
});
+10 -3
View File
@@ -22,11 +22,11 @@ const Button = defineStyleConfig({
},
variants: {
primary: {
bg: '#3E3B3B',
bg: '#111824',
_hover: {
bg: '#3E3B3B',
bg: '#111824',
_disabled: {
bg: '#3E3B3B'
bg: '#111824'
}
},
color: '#FEFEFE'
@@ -148,6 +148,13 @@ const Switch = SwitchHelper.defineMultiStyleConfig({
export const theme = extendTheme({
initialColorMode: 'light',
colors: ThemeColors,
styles: {
global: {
'html, body': {
fontFamily: 'PingFang SC'
}
}
},
components: {
Button,
Input,
+15 -17
View File
@@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb';
export enum ClusterType {
Standard = 'Standard',
Enterprise = 'Enterprise',
ScaledStandard = 'ScaledStandard',
Contact = 'Contact'
}
@@ -20,33 +21,22 @@ export type ClusterDB = {
kubeSystemID?: string; // bind kube-system id
kubeSystemUpdateAt?: Date;
isDeleted?: boolean;
// v1.1
cpu: number;
memory: number;
months: string;
};
export type ClusterRecordPayload = {
uid: string; // user ID
orderID?: string; // order ID
type: ClusterType; // cluster type
};
} & ClusterFormType;
export type CreateClusterParams = {
orderID?: string; // order ID
type: ClusterType; // license type
};
export type ClusterResult = {
clusterId: string; // cluster ID 唯一
uid: string; // user ID 唯一
orderID?: string; // order ID 唯一
licenseID?: ObjectId; // license ID
type: ClusterType; // license type
createdAt: Date;
updatedAt: Date;
// v1 new
displayName?: string;
kubeSystemID?: string;
kubeSystemUpdateAt?: Date;
isDeleted?: boolean;
};
} & ClusterFormType;
export type CommandFormType = {
cloudVersion: string;
@@ -72,3 +62,11 @@ export type CommandFormType = {
certPath: string;
certKeyPath: string;
};
export type ClusterFormType = {
cpu: number;
memory: number;
months: string;
name?: string;
systemId?: string;
};
+14 -6
View File
@@ -1,5 +1,6 @@
import { ObjectId } from 'mongodb';
import { TPayMethod } from './payment';
import { ClusterFormType } from './cluster';
export type LicenseDB = {
_id?: ObjectId; // 唯一
@@ -18,6 +19,10 @@ export type LicenseDB = {
updatedAt: Date; // Modification timestamp
// v1
clusterId: string; // Bind to cluster id
// v1.1
cpu?: number;
memory?: number;
months?: string;
};
export type LicenseRecordPayload = {
@@ -26,10 +31,11 @@ export type LicenseRecordPayload = {
orderID: string; // order number
payMethod: TPayMethod;
quota: number; // 额度
amount: number; // 重置金额
amount: number; // 金额
type: LicenseType;
clusterId: string;
};
expiredTime: number;
} & ClusterFormType;
// new
export type LicenseToken = {
@@ -38,13 +44,15 @@ export type LicenseToken = {
// ClusterDB kubeSystemID
clusterID: string;
data: {
amount: number;
nodeCount: number;
totalCPU: number;
totalMemory: number;
};
};
export type LicenseType = 'Account' | 'Cluster';
export type LicenseType = 'Cluster'; // 'Account';
export type CreateLicenseParams = {
orderID: string;
orderID?: string;
clusterId: string;
};
} & ClusterFormType;
+17
View File
@@ -1,5 +1,22 @@
import { cpuPriceMonth, memoryPriceMonth } from '@/constant/product';
import { ClusterFormType } from '@/types';
import dayjs from 'dayjs';
export const calculatePrice = (form: ClusterFormType, freeValues: ClusterFormType): number => {
const { cpu, memory, months } = form;
const cpuTotalPrice = cpuPriceMonth * cpu;
const memoryTotalPrice = memoryPriceMonth * memory;
const totalPrice = (cpuTotalPrice + memoryTotalPrice) * parseInt(months);
if (cpu <= freeValues.cpu && memory <= freeValues.memory && months === freeValues.months) {
return 0;
} else {
return totalPrice;
}
};
export const formatTime = (time: string | number | Date, format = 'YYYY-MM-DD HH:mm:ss') => {
return dayjs(time).format(format);
};