diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3da61660c..ac1830d4d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/providers/license/deploy/manifests/deploy.yaml.tmpl b/frontend/providers/license/deploy/manifests/deploy.yaml.tmpl index a71065def..60c0b1449 100644 --- a/frontend/providers/license/deploy/manifests/deploy.yaml.tmpl +++ b/frontend/providers/license/deploy/manifests/deploy.yaml.tmpl @@ -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 diff --git a/frontend/providers/license/package.json b/frontend/providers/license/package.json index 424918c88..b29fe1a41 100644 --- a/frontend/providers/license/package.json +++ b/frontend/providers/license/package.json @@ -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", diff --git a/frontend/providers/license/src/api/license.ts b/frontend/providers/license/src/api/license.ts index ff8dea3d9..fbf1500a4 100644 --- a/frontend/providers/license/src/api/license.ts +++ b/frontend/providers/license/src/api/license.ts @@ -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('/api/license/getLicense', { page, pageSize }); + +export const getLicenseByName = ({ name }: { name: string }) => + GET(`/api/license/get?name=${name}`); + +export const checkLicenses = () => GET(`/api/license/checkLicenses`); diff --git a/frontend/providers/license/src/components/FileSelect/index.tsx b/frontend/providers/license/src/components/FileSelect/index.tsx index f7e225e6d..0d78434f5 100644 --- a/frontend/providers/license/src/components/FileSelect/index.tsx +++ b/frontend/providers/license/src/components/FileSelect/index.tsx @@ -28,9 +28,10 @@ interface Props extends BoxProps { fileExtension: string; files: FileItemType[]; setFiles: React.Dispatch>; + 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([]); files.forEach((file) => { - console.log(file, 'fiel'); - promise = promise.then(async (result) => { const extension = file?.name?.split('.')?.pop()?.toLowerCase(); diff --git a/frontend/providers/license/src/constants/key.ts b/frontend/providers/license/src/constants/key.ts new file mode 100644 index 000000000..56052fd6d --- /dev/null +++ b/frontend/providers/license/src/constants/key.ts @@ -0,0 +1 @@ +export const LicenseFrontendKey = 'cloud.sealos.io/license-frontend'; diff --git a/frontend/providers/license/src/pages/api/cronjob/init.ts b/frontend/providers/license/src/pages/api/cronjob/init.ts new file mode 100644 index 000000000..c4f3da207 --- /dev/null +++ b/frontend/providers/license/src/pages/api/cronjob/init.ts @@ -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.' + }); +} diff --git a/frontend/providers/license/src/pages/api/license/checkLicenses.ts b/frontend/providers/license/src/pages/api/license/checkLicenses.ts new file mode 100644 index 000000000..1f06d7ff1 --- /dev/null +++ b/frontend/providers/license/src/pages/api/license/checkLicenses.ts @@ -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 => { + 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) { + 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 + }); + } +} diff --git a/frontend/providers/license/src/pages/api/license/get.ts b/frontend/providers/license/src/pages/api/license/get.ts new file mode 100644 index 000000000..4542bfee8 --- /dev/null +++ b/frontend/providers/license/src/pages/api/license/get.ts @@ -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) { + 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 + }); + } +} diff --git a/frontend/providers/license/src/pages/api/license/getLicense.ts b/frontend/providers/license/src/pages/api/license/getLicense.ts index 55e5f3992..0c1b3f7f2 100644 --- a/frontend/providers/license/src/pages/api/license/getLicense.ts +++ b/frontend/providers/license/src/pages/api/license/getLicense.ts @@ -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) { @@ -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 diff --git a/frontend/providers/license/src/pages/index.tsx b/frontend/providers/license/src/pages/index.tsx index 46386db2c..296863bdf 100644 --- a/frontend/providers/license/src/pages/index.tsx +++ b/frontend/providers/license/src/pages/index.tsx @@ -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() { - {t('expire_date')}: {formatTime(maxExpTime * 1000)} + {t('expire_date')}: {isClient && formatTime(maxExpTime * 1000)} @@ -178,7 +197,7 @@ export default function LicenseApp() { {t('Activate License')} - + { - 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; }; diff --git a/service/license/package.json b/service/license/package.json index 5fbd47637..62fefde81 100644 --- a/service/license/package.json +++ b/service/license/package.json @@ -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", diff --git a/service/license/pnpm-lock.yaml b/service/license/pnpm-lock.yaml index 317dc8755..be9a805b2 100644 --- a/service/license/pnpm-lock.yaml +++ b/service/license/pnpm-lock.yaml @@ -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: diff --git a/service/license/src/api/cluster.ts b/service/license/src/api/cluster.ts index bc945e55d..5563d6a4a 100644 --- a/service/license/src/api/cluster.ts +++ b/service/license/src/api/cluster.ts @@ -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('/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('/api/cluster/findById', payload); + GET('/api/cluster/findById', payload); export const updateClusterName = (payload: { clusterId: string; displayName: string }) => - POST('/api/cluster/updateName', payload); + POST('/api/cluster/updateName', payload); export const deleteClusterById = (payload: { clusterId: string }) => - DELETE('/api/cluster/delete', payload); + DELETE('/api/cluster/delete', payload); export const isKubeSystemIDBound = (id: string) => GET<{ isBound: boolean }>('/api/cluster/isBound', { kubeSystemID: id }); export const activeClusterBySystemId = (payload: ActiveClusterParams) => - POST('/api/cluster/activeCluster', payload); + POST('/api/cluster/activeCluster', payload); export const findClusterBySystemId = (payload: { systemId: string }) => - GET('/api/cluster/findBySystemId', payload); + GET('/api/cluster/findBySystemId', payload); diff --git a/service/license/src/components/Icon/ArrowDown.tsx b/service/license/src/components/Icon/ArrowDown.tsx new file mode 100644 index 000000000..d3c4acff8 --- /dev/null +++ b/service/license/src/components/Icon/ArrowDown.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from '@chakra-ui/react'; + +export const ArrowDown = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/service/license/src/components/Icon/ArrowUp.tsx b/service/license/src/components/Icon/ArrowUp.tsx new file mode 100644 index 000000000..c611a54fc --- /dev/null +++ b/service/license/src/components/Icon/ArrowUp.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from '@chakra-ui/react'; + +export const ArrowUp = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/service/license/src/components/Icon/CpuIcon.tsx b/service/license/src/components/Icon/CpuIcon.tsx new file mode 100644 index 000000000..a385c1443 --- /dev/null +++ b/service/license/src/components/Icon/CpuIcon.tsx @@ -0,0 +1,21 @@ +import { Icon, IconProps } from '@chakra-ui/react'; + +export const CpuIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/service/license/src/components/Icon/MemoryIcon.tsx b/service/license/src/components/Icon/MemoryIcon.tsx new file mode 100644 index 000000000..be8f6837d --- /dev/null +++ b/service/license/src/components/Icon/MemoryIcon.tsx @@ -0,0 +1,29 @@ +import { Icon, IconProps } from '@chakra-ui/react'; + +export const MemoryIcon = (props: IconProps) => { + return ( + + + + + + ); +}; diff --git a/service/license/src/components/Layout/index.tsx b/service/license/src/components/Layout/index.tsx index e573c5cfb..bd20ad23a 100644 --- a/service/license/src/components/Layout/index.tsx +++ b/service/license/src/components/Layout/index.tsx @@ -86,7 +86,6 @@ export default function Layout({ children }: { children: ReactNode }) { ); })} - {/* */} diff --git a/service/license/src/components/Select/index.tsx b/service/license/src/components/Select/index.tsx new file mode 100644 index 000000000..3ac25a996 --- /dev/null +++ b/service/license/src/components/Select/index.tsx @@ -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(null); + const SelectRef = useRef(null); + const { isOpen, onOpen, onClose } = useDisclosure(); + + useOutsideClick({ + ref: SelectRef, + handler: () => { + onClose(); + } + }); + + const activeMenu = useMemo(() => list.find((item) => item.value === value), [list, value]); + + return ( + + { + isOpen ? onClose() : onOpen(); + }} + > + } + 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 ? ( + <> + {activeMenu.label} + + ) : ( + <> + {placeholder} + + )} + + + { + 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) => ( + { + if (onchange && value !== item.value) { + onchange(item.value); + } + }} + > + {item.label} + + ))} + + + + ); +}; + +export default forwardRef(MySelect); diff --git a/service/license/src/components/billing-meter/index.tsx b/service/license/src/components/billing-meter/index.tsx new file mode 100644 index 000000000..b918ad797 --- /dev/null +++ b/service/license/src/components/billing-meter/index.tsx @@ -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; + 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 ( + + {priceMode && ( + + 计价方案 + + )} + + {priceMode && ( + <> + + 单价 + + + CPU: {cpuPriceMonth * 12} 元/核心/年 + + + + 内存: {memoryPriceMonth * 12} 元/G/年 + + + + + )} + + {!priceMode && !isPayLicense && ( + + + 集群名 + + + + )} + + {formArr.map((item) => { + const list = item.sliderList; + return ( + + + {item.label} + + + ( + + {list.map((n, i) => ( + + {n.value} {i + 1 === CpuSlideMarkList.length && item.unit} + + ))} + + + + + + )} + /> + + ( + { + const value = Number(valueString); + field.onChange(valueString === '' || isNaN(value) ? item.min : value); + }} + > + + + + + + + + + + + + {item.unit} + + + )} + /> + + + ); + })} + + + + 时间 + + setValue('months', val)} + /> + + + + { + formHook.reset({ + cpu: freeClusterForm.cpu, + memory: freeClusterForm.memory, + months: freeClusterForm.months, + name: getValues('name'), + systemId: getValues('systemId') + }); + }} + > + 免费规模 + + + + + + + {priceMode ? '价格:' : '需支付:'} + + + ¥ {price} + + + + + ); + } +); + +BillingMeter.displayName = 'BillingMeter'; + +export default BillingMeter; diff --git a/service/license/src/components/Signin/auth/useAuthList.tsx b/service/license/src/components/login-module/auth/useAuthList.tsx similarity index 100% rename from service/license/src/components/Signin/auth/useAuthList.tsx rename to service/license/src/components/login-module/auth/useAuthList.tsx diff --git a/service/license/src/components/Signin/auth/useCustomError.tsx b/service/license/src/components/login-module/auth/useCustomError.tsx similarity index 100% rename from service/license/src/components/Signin/auth/useCustomError.tsx rename to service/license/src/components/login-module/auth/useCustomError.tsx diff --git a/service/license/src/components/Signin/auth/useLanguage.tsx b/service/license/src/components/login-module/auth/useLanguage.tsx similarity index 100% rename from service/license/src/components/Signin/auth/useLanguage.tsx rename to service/license/src/components/login-module/auth/useLanguage.tsx diff --git a/service/license/src/components/Signin/auth/usePassword.tsx b/service/license/src/components/login-module/auth/usePassword.tsx similarity index 100% rename from service/license/src/components/Signin/auth/usePassword.tsx rename to service/license/src/components/login-module/auth/usePassword.tsx diff --git a/service/license/src/components/Signin/auth/useProtocol.tsx b/service/license/src/components/login-module/auth/useProtocol.tsx similarity index 100% rename from service/license/src/components/Signin/auth/useProtocol.tsx rename to service/license/src/components/login-module/auth/useProtocol.tsx diff --git a/service/license/src/components/Signin/auth/useSms.tsx b/service/license/src/components/login-module/auth/useSms.tsx similarity index 100% rename from service/license/src/components/Signin/auth/useSms.tsx rename to service/license/src/components/login-module/auth/useSms.tsx diff --git a/service/license/src/components/Signin/background.tsx b/service/license/src/components/login-module/background.tsx similarity index 100% rename from service/license/src/components/Signin/background.tsx rename to service/license/src/components/login-module/background.tsx diff --git a/service/license/src/components/Signin/index.tsx b/service/license/src/components/login-module/index.tsx similarity index 93% rename from service/license/src/components/Signin/index.tsx rename to service/license/src/components/login-module/index.tsx index d81dbaa70..88dc593bf 100644 --- a/service/license/src/components/Signin/index.tsx +++ b/service/license/src/components/login-module/index.tsx @@ -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 { diff --git a/service/license/src/components/recharge/index.tsx b/service/license/src/components/recharge/index.tsx new file mode 100644 index 000000000..64de25416 --- /dev/null +++ b/service/license/src/components/recharge/index.tsx @@ -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('wechat'); + const [detail, setDetail] = useState(false); + const [orderID, setOrderID] = useState(''); + const [wechatPaymentData, setWechatPaymentData] = useState(); + const { data: platformEnv } = useQuery(['getPlatformEnv'], getSystemEnv); + // 用于避免微信支付,窗口关闭后感知不到的问题 + const { paymentData, setPaymentData, deletePaymentData, isExpired } = usePaymentDataStore(); + const [price, setPrice] = useState(0); + + const formHook = useForm(); + + // 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 ( + + + + + {detail ? ( + <> + { + setOrderID(''); + setComplete(0); + setDetail(false); + }} + display={'flex'} + alignItems={'center'} + > + + + {isLicensePay ? '购买 License' : '新建集群'} + + + {complete === 1 ? ( + + + + ) : ( + + )} + + ) : ( + <> + + {isLicensePay ? '购买 License' : '新建集群'} + + + + + {price === 0 ? ( + + ) : ( + + {platformEnv?.stripeEnabled && ( + + )} + {platformEnv?.wechatEnabledRecharge && ( + + )} + + )} + + + + { + setIsInvalid(false); + setIsAgree(e.target.checked); + }} + /> + + {t('agree policy')} + + {t('Service Agreement')} + + {t('and')} + + {t('Privacy Policy')} + + + + + + )} + + + ); +}); + +export default RechargeComponent; diff --git a/service/license/src/components/signin/auth/useAuthList.tsx b/service/license/src/components/signin/auth/useAuthList.tsx deleted file mode 100644 index fea1a1dde..000000000 --- a/service/license/src/components/signin/auth/useAuthList.tsx +++ /dev/null @@ -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 ( - - {authList - .filter((item) => item.need) - .map((item, index) => ( - - ))} - - ); - }; - - return { - AuthList - }; -}; - -export default useAuthList; diff --git a/service/license/src/components/signin/auth/useCustomError.tsx b/service/license/src/components/signin/auth/useCustomError.tsx deleted file mode 100644 index 7b807b717..000000000 --- a/service/license/src/components/signin/auth/useCustomError.tsx +++ /dev/null @@ -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 ? ( - - - {t(error)} - - - ) : null; - }; - - return { showError, ErrorComponent }; -}; - -export default useCustomError; diff --git a/service/license/src/components/signin/auth/useLanguage.tsx b/service/license/src/components/signin/auth/useLanguage.tsx deleted file mode 100644 index 8aaecd6e9..000000000 --- a/service/license/src/components/signin/auth/useLanguage.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -export default Language; diff --git a/service/license/src/components/signin/auth/usePassword.tsx b/service/license/src/components/signin/auth/usePassword.tsx deleted file mode 100644 index d02b8f0c6..000000000 --- a/service/license/src/components/signin/auth/usePassword.tsx +++ /dev/null @@ -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 ; - } else { - return ; - } - }; - - const PasswordModal = () => { - return ( - <> - - - - - - - - - - - - - - ); - }; - - const ConfirmPasswordModal = () => { - return ( - <> - - setPageState(0)} - /> - {t('Verify password')} - - - - - - - - - ); - }; - - return { - PasswordComponent, - login, - userExist, - pageState, - isLoading - }; -} diff --git a/service/license/src/components/signin/auth/useProtocol.tsx b/service/license/src/components/signin/auth/useProtocol.tsx deleted file mode 100644 index 36574dab2..000000000 --- a/service/license/src/components/signin/auth/useProtocol.tsx +++ /dev/null @@ -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 = () => ( - - { - setIsInvalid(false); - setIsAgree(e.target.checked); - }} - /> - - {t('agree policy')} - - {t('Service Agreement')} - - {t('and')} - - {t('Privacy Policy')} - - - - ); - return { - Protocol, - isAgree, - setIsInvalid - }; -}; - -export default useProtocol; diff --git a/service/license/src/components/signin/auth/useSms.tsx b/service/license/src/components/signin/auth/useSms.tsx deleted file mode 100644 index 3cceb2794..000000000 --- a/service/license/src/components/signin/auth/useSms.tsx +++ /dev/null @@ -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 ( - <> - - - - +86 - - - - - - {remainTime <= 0 ? ( - - {t('Get Code')} - - ) : ( - {remainTime} s - )} - - - - - - - - - - {getValues('verifyCode')?.length === 6 && } - - - - ); - }; - - return { - SmsModal, - login, - isLoading - }; -} diff --git a/service/license/src/components/signin/background.tsx b/service/license/src/components/signin/background.tsx deleted file mode 100644 index 7fd3f1b47..000000000 --- a/service/license/src/components/signin/background.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Box, Flex, Image, Text } from '@chakra-ui/react'; - -export default function Background() { - return ( - <> - bg - bg - bg - - logo - - Sealos - - - | License - - - - ); -} diff --git a/service/license/src/components/signin/index.tsx b/service/license/src/components/signin/index.tsx deleted file mode 100644 index d81dbaa70..000000000 --- a/service/license/src/components/signin/index.tsx +++ /dev/null @@ -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.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: - }, - [LoginType.PASSWORD]: { - login: passwordSubmit, - component: - }, - [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 ( - - - - Sealos Cloud - - - - - - - - - - {pageState === 0 && needTabs && ( - setTabIndex(idx)} - variant="unstyled" - p={'0'} - width={'full'} - > - - - {t('Verification Code Login')} - - - {t('Password Login')} - - - - - )} - - {LoginComponent} - - - - - - - - - - ); -} diff --git a/service/license/src/constant/product.ts b/service/license/src/constant/product.ts index 417717dbb..00e32ec50 100644 --- a/service/license/src/constant/product.ts +++ b/service/license/src/constant/product.ts @@ -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; // ¥ diff --git a/service/license/src/pages/api/cluster/activeCluster.ts b/service/license/src/pages/api/cluster/activeCluster.ts index 30a774aed..0c3b3551b 100644 --- a/service/license/src/pages/api/cluster/activeCluster.ts +++ b/service/license/src/pages/api/cluster/activeCluster.ts @@ -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, { diff --git a/service/license/src/pages/api/cluster/create.ts b/service/license/src/pages/api/cluster/create.ts index bf5251b03..4ab10a116 100644 --- a/service/license/src/pages/api/cluster/create.ts +++ b/service/license/src/pages/api/cluster/create.ts @@ -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 }); diff --git a/service/license/src/pages/api/license/create.ts b/service/license/src/pages/api/license/create.ts index c341ca742..ef0718ffb 100644 --- a/service/license/src/pages/api/license/create.ts +++ b/service/license/src/pages/api/license/create.ts @@ -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) { diff --git a/service/license/src/pages/cluster/components/ClusterPanel.tsx b/service/license/src/pages/cluster/components/ClusterPanel.tsx index 83027374f..e320a7cda 100644 --- a/service/license/src/pages/cluster/components/ClusterPanel.tsx +++ b/service/license/src/pages/cluster/components/ClusterPanel.tsx @@ -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'] }); diff --git a/service/license/src/pages/cluster/components/ConfigForm.tsx b/service/license/src/pages/cluster/components/ConfigForm.tsx index 1e27c8b62..28cf5c689 100644 --- a/service/license/src/pages/cluster/components/ConfigForm.tsx +++ b/service/license/src/pages/cluster/components/ConfigForm.tsx @@ -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); diff --git a/service/license/src/pages/cluster/components/LicensePanel.tsx b/service/license/src/pages/cluster/components/LicensePanel.tsx index 2b7630689..c46926a14 100644 --- a/service/license/src/pages/cluster/components/LicensePanel.tsx +++ b/service/license/src/pages/cluster/components/LicensePanel.tsx @@ -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() { {data?.records && data?.records?.length > 0 ? ( - + {data?.records.map((item) => ( - + License - {item.amount} + {item?.amount} - - {t('Remaining Time')} {getRemainingTime(item.exp)} - + {item?.cpu && ( + + CPU: {item?.cpu} Core + 内存: {item?.memory} G + 有效时间: {getRemainingTime(item.exp)} + + )} @@ -122,7 +144,7 @@ export default function License() { )} - + ); } diff --git a/service/license/src/pages/cluster/components/List.tsx b/service/license/src/pages/cluster/components/List.tsx index 183de7656..c8141ab68 100644 --- a/service/license/src/pages/cluster/components/List.tsx +++ b/service/license/src/pages/cluster/components/List.tsx @@ -63,7 +63,7 @@ export default function ClusterRecord() { cursor={'pointer'} > - + {item?.displayName ? item.displayName : `${t('Cluster')}`} -
- {t(item.type)} -
+ + 集群ID: {item?.kubeSystemID ? item?.kubeSystemID : '未绑定'} +
- - 集群ID: {item?.kubeSystemID ? item?.kubeSystemID : '未绑定'} - {/* {formatTime(item.createdAt, 'YYYY-MM-DD HH:mm')} */} - + + {item?.cpu && ( + + CPU: {item?.cpu} Core + 内存: {item?.memory} G + + )}
{item?.kubeSystemID ? ( diff --git a/service/license/src/pages/cluster/components/Recharge.tsx b/service/license/src/pages/cluster/components/Recharge.tsx deleted file mode 100644 index 7718741ea..000000000 --- a/service/license/src/pages/cluster/components/Recharge.tsx +++ /dev/null @@ -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('wechat'); - const [detail, setDetail] = useState(false); - const [orderID, setOrderID] = useState(''); - const [wechatPaymentData, setWechatPaymentData] = useState(); - 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 ( - - - - - {detail ? ( - <> - { - setOrderID(''); - setComplete(0); - setDetail(false); - }} - display={'flex'} - alignItems={'center'} - > - - - {t('Purchase License')} - - - {complete === 1 ? ( - - - - ) : ( - - )} - - ) : ( - <> - {t('Purchase License')} - - - {t('Select Amount')} - - - - { - setIsInvalid(false); - setIsAgree(e.target.checked); - }} - /> - - {t('agree policy')} - - {t('Service Agreement')} - - {t('and')} - - {t('Privacy Policy')} - - - {/* */} - - - {platformEnv?.stripeEnabled && ( - - )} - - {platformEnv?.wechatEnabledRecharge && ( - - )} - - - - )} - - - ); -}); diff --git a/service/license/src/pages/cluster/components/Tutorial.tsx b/service/license/src/pages/cluster/components/Tutorial.tsx index 47e7b9502..9ec006e3e 100644 --- a/service/license/src/pages/cluster/components/Tutorial.tsx +++ b/service/license/src/pages/cluster/components/Tutorial.tsx @@ -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) - {clusterDetail?.type === ClusterType.Enterprise && ( + {clusterDetail?.type !== ClusterType.Standard && ( 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) { diff --git a/service/license/src/pages/license/components/Record.tsx b/service/license/src/pages/license/components/Record.tsx index 0b6fcd1c6..308a317b9 100644 --- a/service/license/src/pages/license/components/Record.tsx +++ b/service/license/src/pages/license/components/Record.tsx @@ -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 ( diff --git a/service/license/src/pages/pricing/components/Product.tsx b/service/license/src/pages/pricing/components/Product.tsx index a1e5715cf..a6c42da69 100644 --- a/service/license/src/pages/pricing/components/Product.tsx +++ b/service/license/src/pages/pricing/components/Product.tsx @@ -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.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('wechat'); - const [clusterType, setClusterType] = useState(ClusterType.Standard); - const [orderID, setOrderID] = useState(''); - const [wechatData, setWechatData] = useState(); - 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({ + 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" > - - + + 标准版 - - - ¥0 - -
- 赠 ¥299 -
-
- - 适合开发者测试,或 POC demo - - -
- - - 企业版 - - - - ¥599 - -
- 赠 ¥599 -
-
- - 适合企业生产环境 + + 适合开发者测试, POC demo,企业生产环境
定制版 - + 适合大规模集群与大型企业客户 -
- )} - - - + ); } diff --git a/service/license/src/pages/pricing/components/ServicePackage.tsx b/service/license/src/pages/pricing/components/ServicePackage.tsx index e514d180a..b37914eb4 100644 --- a/service/license/src/pages/pricing/components/ServicePackage.tsx +++ b/service/license/src/pages/pricing/components/ServicePackage.tsx @@ -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) {item.label} diff --git a/service/license/src/pages/signin.tsx b/service/license/src/pages/signin.tsx index 2d6c4dd51..12ee9b218 100644 --- a/service/license/src/pages/signin.tsx +++ b/service/license/src/pages/signin.tsx @@ -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 diff --git a/service/license/src/services/backend/db/cluster.ts b/service/license/src/services/backend/db/cluster.ts index 743b38f99..14d78fdf9 100644 --- a/service/license/src/services/backend/db/cluster.ts +++ b/service/license/src/services/backend/db/cluster.ts @@ -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({ diff --git a/service/license/src/services/backend/db/license.ts b/service/license/src/services/backend/db/license.ts index d99ba9116..fbbec7f09 100644 --- a/service/license/src/services/backend/db/license.ts +++ b/service/license/src/services/backend/db/license.ts @@ -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'); diff --git a/service/license/src/stores/cluster.ts b/service/license/src/stores/cluster.ts index 2fbd40dcb..f6950cd4a 100644 --- a/service/license/src/stores/cluster.ts +++ b/service/license/src/stores/cluster.ts @@ -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; }; diff --git a/service/license/src/stores/payment.ts b/service/license/src/stores/payment.ts index da03ca99d..0f0176cb3 100644 --- a/service/license/src/stores/payment.ts +++ b/service/license/src/stores/payment.ts @@ -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((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 } }); diff --git a/service/license/src/styles/chakraTheme.ts b/service/license/src/styles/chakraTheme.ts index 3d54028ab..ebc08fb19 100644 --- a/service/license/src/styles/chakraTheme.ts +++ b/service/license/src/styles/chakraTheme.ts @@ -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, diff --git a/service/license/src/types/cluster.ts b/service/license/src/types/cluster.ts index 6d61d8ead..27f84ad8c 100644 --- a/service/license/src/types/cluster.ts +++ b/service/license/src/types/cluster.ts @@ -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; +}; diff --git a/service/license/src/types/license.ts b/service/license/src/types/license.ts index 1c0c6a6ca..359544db7 100644 --- a/service/license/src/types/license.ts +++ b/service/license/src/types/license.ts @@ -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; diff --git a/service/license/src/utils/tools.ts b/service/license/src/utils/tools.ts index 9f78d0df7..aa60b8afe 100644 --- a/service/license/src/utils/tools.ts +++ b/service/license/src/utils/tools.ts @@ -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); };