11 changed files with 414 additions and 12 deletions
			
			
		- 
					24src/layout/ListPageLayout.tsx
 - 
					242src/pages/websites/ssl/index.tsx
 - 
					13src/pages/websites/ssl/style.ts
 - 
					4src/routes.tsx
 - 
					9src/service/websites.ts
 - 
					0src/store/websites/ca.ts
 - 
					10src/store/websites/dns.ts
 - 
					64src/store/websites/ssl.ts
 - 
					11src/types/ca.ts
 - 
					10src/types/dns.ts
 - 
					39src/types/ssl.d.ts
 
@ -1,21 +1,25 @@ | 
				
			|||
import React from 'react' | 
				
			|||
import { createLazyRoute, Outlet } from '@tanstack/react-router' | 
				
			|||
import { useStyle } from '@/layout/style.ts' | 
				
			|||
import { PageContainer, PageContainerProps } from '@ant-design/pro-components' | 
				
			|||
 | 
				
			|||
interface IListPageLayoutProps { | 
				
			|||
interface IListPageLayoutProps extends PageContainerProps { | 
				
			|||
    children: React.ReactNode | 
				
			|||
 | 
				
			|||
} | 
				
			|||
 | 
				
			|||
const ListPageLayout: React.FC<IListPageLayoutProps> = (props) => { | 
				
			|||
const ListPageLayout: React.FC<IListPageLayoutProps> = ({ children, ...props }) => { | 
				
			|||
    const { styles } = useStyle({ className: 'two-col' }) | 
				
			|||
 | 
				
			|||
 | 
				
			|||
    return ( | 
				
			|||
            <>{props.children} | 
				
			|||
                <Outlet/> | 
				
			|||
            <> | 
				
			|||
                <PageContainer | 
				
			|||
                        breadcrumbRender={false} title={false} className={styles.container} | 
				
			|||
                        {...props} | 
				
			|||
                > | 
				
			|||
                    {children} | 
				
			|||
                </PageContainer> | 
				
			|||
            </> | 
				
			|||
    ) | 
				
			|||
} | 
				
			|||
 | 
				
			|||
export default ListPageLayout | 
				
			|||
 | 
				
			|||
export const GenRoute = (id: string) => createLazyRoute(id)({ | 
				
			|||
    component: ListPageLayout, | 
				
			|||
}) | 
				
			|||
@ -0,0 +1,242 @@ | 
				
			|||
import { useAtom, useAtomValue } from 'jotai' | 
				
			|||
import { ProviderTypeEnum, saveOrUpdateSslAtom, sslListAtom, sslPageAtom, sslSearchAtom } from '@/store/websites/ssl.ts' | 
				
			|||
import ListPageLayout from '@/layout/ListPageLayout.tsx' | 
				
			|||
import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components' | 
				
			|||
import { memo, useMemo, useState } from 'react' | 
				
			|||
import { useTranslation } from '@/i18n.ts' | 
				
			|||
import { Button, Form, Popconfirm } from 'antd' | 
				
			|||
import { PlusOutlined } from '@ant-design/icons' | 
				
			|||
import { ISSL } from '@/types/ssl' | 
				
			|||
 | 
				
			|||
 | 
				
			|||
const SSL = () => { | 
				
			|||
 | 
				
			|||
    const { t } = useTranslation() | 
				
			|||
    const [ form ] = Form.useForm() | 
				
			|||
    const [ page, setPage ] = useAtom(sslPageAtom) | 
				
			|||
    const [ search, setSearch ] = useAtom(sslSearchAtom) | 
				
			|||
    const { data, isLoading, isFetching, refetch } = useAtomValue(sslListAtom) | 
				
			|||
    const { mutate: saveOrUpdate, isPending: isSubmitting } = useAtomValue(saveOrUpdateSslAtom) | 
				
			|||
 | 
				
			|||
    const [ open, setOpen ] = useState(false) | 
				
			|||
 | 
				
			|||
    const columns = useMemo<ProColumns<ISSL>[]>(() => { | 
				
			|||
        return [ | 
				
			|||
            { | 
				
			|||
                title: 'ID', | 
				
			|||
                dataIndex: 'id', | 
				
			|||
                hideInTable: true, | 
				
			|||
                hideInSearch: false, | 
				
			|||
                formItemProps: { | 
				
			|||
                    hidden: true, | 
				
			|||
                } | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.primaryDomain', '域名'), | 
				
			|||
                dataIndex: 'primaryDomain', | 
				
			|||
                formItemProps:{ | 
				
			|||
                    label: t('website.ssl.form.primaryDomain', '主域名'), | 
				
			|||
                    rules: [ { required: true, message: t('message.required', '主域名') } ] | 
				
			|||
                } | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.otherDomains', '其它域名'), | 
				
			|||
                dataIndex: 'otherDomains', | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.acmeAccountId', 'Acme帐号'), | 
				
			|||
                dataIndex: 'acmeAccountId', | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.provider', '申请方式'), | 
				
			|||
                dataIndex: 'provider', | 
				
			|||
                valueType: 'radio', | 
				
			|||
                initialValue: ProviderTypeEnum.DnsAccount, | 
				
			|||
                valueEnum: { | 
				
			|||
                    [ProviderTypeEnum.DnsAccount]: { | 
				
			|||
                        text: t('website.ssl.providerTypeEnum.DnsAccount', 'DNS帐号'), | 
				
			|||
                    }, | 
				
			|||
                    [ProviderTypeEnum.DnsManual]: { | 
				
			|||
                        text: t('website.ssl.providerTypeEnum.DnsManual', '手动验证'), | 
				
			|||
                    }, | 
				
			|||
                    [ProviderTypeEnum.Http]: { | 
				
			|||
                        text: t('website.ssl.providerTypeEnum.Http', 'HTTP'), | 
				
			|||
                    } | 
				
			|||
                }, | 
				
			|||
                dependencies : [ 'provider' ], | 
				
			|||
                formItemProps: (form, config)=> { | 
				
			|||
                    const val = form.getFieldValue(config.dataIndex) | 
				
			|||
                    const help = { | 
				
			|||
                        [ProviderTypeEnum.DnsAccount]: t('website.ssl.form.provider_{{v}}', '', { v: val }), | 
				
			|||
                        [ProviderTypeEnum.DnsManual]: t('website.ssl.form.provider_{{v}}', '手动解析模式需要在创建完之后点击申请按钮获取 DNS 解析值', { v: val }), | 
				
			|||
                        [ProviderTypeEnum.Http]: t('website.ssl.form.provider_{{v}}', 'HTTP 模式需要安装 OpenResty<br/><span style="color:red;">HTTP 模式无法申请泛域名证书</span>', { v: val }), | 
				
			|||
                    } | 
				
			|||
                    return { | 
				
			|||
                        label: t('website.ssl.form.provider', '验证方式'), | 
				
			|||
                        help:  <span dangerouslySetInnerHTML={{__html: help[val]}} />, | 
				
			|||
                        rules: [ { required: true, message: t('message.required', '请选择') } ] | 
				
			|||
                    } | 
				
			|||
                }, | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                name: [ 'provider' ], | 
				
			|||
                valueType: 'dependency', | 
				
			|||
                columns: ({ provider }) => { | 
				
			|||
                    if (provider === ProviderTypeEnum.DnsAccount) { | 
				
			|||
                        return [ { | 
				
			|||
                            title: t('website.ssl.columns.dnsAccountId', 'DNS帐号'), | 
				
			|||
                            dataIndex: 'dnsAccountId', | 
				
			|||
                        } ] | 
				
			|||
                    } | 
				
			|||
                    return [] | 
				
			|||
 | 
				
			|||
                } | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.autoRenew', '自动续签'), | 
				
			|||
                dataIndex: 'autoRenew', | 
				
			|||
                valueType: 'switch', | 
				
			|||
 | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.pushDir', '推送证书到本地目录'), | 
				
			|||
                dataIndex: 'pushDir', | 
				
			|||
                valueType: 'switch', | 
				
			|||
                hideInTable: true, | 
				
			|||
                hideInSearch: true, | 
				
			|||
 | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                name: [ 'pushDir' ], | 
				
			|||
                valueType: 'dependency', | 
				
			|||
                columns: ({ pushDir }) => { | 
				
			|||
                    if (pushDir) { | 
				
			|||
                        return [ { | 
				
			|||
                            title: t('website.ssl.columns.dir', '目录'), | 
				
			|||
                            dataIndex: 'dir', | 
				
			|||
                            formItemProps: { | 
				
			|||
                                help: t('website.ssl.form.dir_help', '会在此目录下生成两个文件,证书文件:fullchain.pem 密钥文件:privkey.pem'), | 
				
			|||
                                rules: [ { required: true, message: t('message.required', '请输入目录') } ] | 
				
			|||
                            } | 
				
			|||
                        } ] | 
				
			|||
                    } | 
				
			|||
                    return [] | 
				
			|||
 | 
				
			|||
                } | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.description', '备注'), | 
				
			|||
                dataIndex: 'description', | 
				
			|||
            }, | 
				
			|||
            { | 
				
			|||
                title: t('website.ssl.columns.option', '操作'), valueType: 'option', | 
				
			|||
                key: 'option', | 
				
			|||
                render: (_, record) => [ | 
				
			|||
                    <a key="editable" | 
				
			|||
                       onClick={() => { | 
				
			|||
 | 
				
			|||
                       }} | 
				
			|||
                    > | 
				
			|||
                        {t('actions.edit', '编辑')} | 
				
			|||
                    </a>, | 
				
			|||
                    <Popconfirm | 
				
			|||
                            key={'del_confirm'} | 
				
			|||
                            // disabled={isPending}
 | 
				
			|||
                            onConfirm={() => { | 
				
			|||
                                // deleteUser([ record.id ])
 | 
				
			|||
                            }} | 
				
			|||
                            title={t('message.deleteConfirm')}> | 
				
			|||
                        <a key="del"> | 
				
			|||
                            {t('actions.delete', '删除')} | 
				
			|||
                        </a> | 
				
			|||
                    </Popconfirm> | 
				
			|||
                    , | 
				
			|||
                ], | 
				
			|||
            }, | 
				
			|||
        ] | 
				
			|||
    }, []) | 
				
			|||
 | 
				
			|||
    return ( | 
				
			|||
            <ListPageLayout> | 
				
			|||
                <ProTable<ISSL> | 
				
			|||
                        headerTitle={t('website.ssl.title', '证书列表')} | 
				
			|||
                        search={false} | 
				
			|||
                        loading={isLoading || isFetching} | 
				
			|||
                        rowKey={'id'} | 
				
			|||
                        dataSource={data?.rows ?? []} | 
				
			|||
                        columns={columns} | 
				
			|||
                        options={{ | 
				
			|||
                            reload: () => { | 
				
			|||
                                refetch() | 
				
			|||
                            }, | 
				
			|||
                        }} | 
				
			|||
                        toolbar={{ | 
				
			|||
                            search: { | 
				
			|||
                                loading: isFetching && !!search.key, | 
				
			|||
                                onSearch: (value: string) => { | 
				
			|||
                                    setSearch({ key: value }) | 
				
			|||
                                }, | 
				
			|||
                                placeholder: t('website.ssl.search.placeholder', '输入域名') | 
				
			|||
                            }, | 
				
			|||
                            actions: [ | 
				
			|||
                                <Button | 
				
			|||
                                        key="button" | 
				
			|||
                                        icon={<PlusOutlined/>} | 
				
			|||
                                        onClick={() => { | 
				
			|||
                                            form.resetFields() | 
				
			|||
                                            form.setFieldsValue({ | 
				
			|||
                                                id: 0, | 
				
			|||
                                            }) | 
				
			|||
                                            setOpen(true) | 
				
			|||
                                        }} | 
				
			|||
                                        type="primary" | 
				
			|||
                                > | 
				
			|||
                                    {t('actions.add', '添加')} | 
				
			|||
                                </Button>, | 
				
			|||
                            ] | 
				
			|||
                        }} | 
				
			|||
                        pagination={{ | 
				
			|||
                            pageSize: page?.pageSize ?? 10, | 
				
			|||
                            total: data?.total ?? 0, | 
				
			|||
                            current: page?.page ?? 1, | 
				
			|||
                            onChange: (page, pageSize) => { | 
				
			|||
                                setPage(prev => ({ | 
				
			|||
                                    ...prev, | 
				
			|||
                                    page, | 
				
			|||
                                    pageSize, | 
				
			|||
                                })) | 
				
			|||
                            }, | 
				
			|||
                        }} | 
				
			|||
                > | 
				
			|||
 | 
				
			|||
                </ProTable> | 
				
			|||
                <BetaSchemaForm<ISSL> | 
				
			|||
                        shouldUpdate={false} | 
				
			|||
                        width={600} | 
				
			|||
                        form={form} | 
				
			|||
                        layout={'vertical'} | 
				
			|||
                        scrollToFirstError={true} | 
				
			|||
                        title={t(`website.ssl.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '证书编辑' : '证书添加')} | 
				
			|||
                        // colProps={{ span: 24 }}
 | 
				
			|||
                        labelCol={{ span: 12 }} | 
				
			|||
                        wrapperCol={{ span: 24 }} | 
				
			|||
                        layoutType={'DrawerForm'} | 
				
			|||
                        open={open} | 
				
			|||
                        drawerProps={{ | 
				
			|||
                            maskClosable: false, | 
				
			|||
                        }} | 
				
			|||
                        onOpenChange={(open) => { | 
				
			|||
                            setOpen(open) | 
				
			|||
                        }} | 
				
			|||
                        loading={isSubmitting} | 
				
			|||
                        onFinish={async (values) => { | 
				
			|||
                            // console.log('values', values)
 | 
				
			|||
                            saveOrUpdate(values) | 
				
			|||
                            return true | 
				
			|||
                        }} | 
				
			|||
                        columns={columns as ProFormColumnsType[]}/> | 
				
			|||
            </ListPageLayout> | 
				
			|||
    ) | 
				
			|||
} | 
				
			|||
 | 
				
			|||
export default memo(SSL) | 
				
			|||
@ -0,0 +1,13 @@ | 
				
			|||
import { createStyles } from '@/theme' | 
				
			|||
 | 
				
			|||
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => { | 
				
			|||
    const prefix = `${prefixCls}-${token?.proPrefix}-ssl-page` | 
				
			|||
 | 
				
			|||
    const container = css`
 | 
				
			|||
 | 
				
			|||
    `
 | 
				
			|||
 | 
				
			|||
    return { | 
				
			|||
        container: cx(prefix, props?.className, container), | 
				
			|||
    } | 
				
			|||
}) | 
				
			|||
@ -0,0 +1,9 @@ | 
				
			|||
import { createCURD } from '@/service/base.ts' | 
				
			|||
 | 
				
			|||
const websitesServ = { | 
				
			|||
    ssl: { | 
				
			|||
        ...createCURD<any, ISsl>('/website/ssl') | 
				
			|||
    } | 
				
			|||
} | 
				
			|||
 | 
				
			|||
export default websitesServ | 
				
			|||
@ -0,0 +1,10 @@ | 
				
			|||
export interface IDnsAccount { | 
				
			|||
    id: number; | 
				
			|||
    createdAt: Date | null; | 
				
			|||
    createdBy: number; | 
				
			|||
    updatedAt: Date | null; | 
				
			|||
    updatedBy: number; | 
				
			|||
    name: string; | 
				
			|||
    type: string; | 
				
			|||
    authorization: string; | 
				
			|||
} | 
				
			|||
@ -0,0 +1,64 @@ | 
				
			|||
import { atom } from 'jotai' | 
				
			|||
import { IApiResult, IPage } from '@/global' | 
				
			|||
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' | 
				
			|||
import websitesServ from '@/service/websites.ts' | 
				
			|||
import { message } from 'antd' | 
				
			|||
import { t } from 'i18next' | 
				
			|||
import { ISSL, SSLSearchParam } from '@/types/ssl' | 
				
			|||
 | 
				
			|||
export enum ProviderTypeEnum { | 
				
			|||
    DnsAccount = 'dnsAccount', | 
				
			|||
    DnsManual = 'dnsManual', | 
				
			|||
    Http = 'http' | 
				
			|||
} | 
				
			|||
 | 
				
			|||
export const sslPageAtom = atom<IPage>({ | 
				
			|||
    page: 1, | 
				
			|||
    pageSize: 20, | 
				
			|||
}) | 
				
			|||
 | 
				
			|||
export const sslSearchAtom = atom<SSLSearchParam>({}) | 
				
			|||
 | 
				
			|||
export const sslListAtom = atomWithQuery(get => ({ | 
				
			|||
    queryKey: [ 'sslList', get(sslPageAtom), get(sslSearchAtom) ], | 
				
			|||
    queryFn: async ({ queryKey: [ , page, search ] }) => { | 
				
			|||
        return await websitesServ.ssl.list({ | 
				
			|||
            ...page as any, | 
				
			|||
            ...search as any, | 
				
			|||
        }) | 
				
			|||
    }, | 
				
			|||
    select: (data) => data.data | 
				
			|||
 | 
				
			|||
})) | 
				
			|||
 | 
				
			|||
//saveOrUpdate
 | 
				
			|||
export const saveOrUpdateSslAtom = atomWithMutation<IApiResult, ISSL>(get => ({ | 
				
			|||
    mutationKey: [ 'sslSaveOrUpdate' ], | 
				
			|||
    mutationFn: async (data: ISSL) => { | 
				
			|||
        const isAdd = data.id === 0 | 
				
			|||
        if (isAdd) { | 
				
			|||
            return await websitesServ.ssl.add(data) | 
				
			|||
        } else { | 
				
			|||
            return await websitesServ.ssl.update(data) | 
				
			|||
        } | 
				
			|||
    }, | 
				
			|||
    onSuccess: (res) => { | 
				
			|||
        const isAdd = !!res.data?.id | 
				
			|||
        message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功')) | 
				
			|||
 | 
				
			|||
        get(sslListAtom).refetch() | 
				
			|||
        return res | 
				
			|||
    }, | 
				
			|||
})) | 
				
			|||
 | 
				
			|||
//delete
 | 
				
			|||
export const deleteSslAtom = atomWithMutation<IApiResult, number>(get => ({ | 
				
			|||
    mutationKey: [ 'sslDelete' ], | 
				
			|||
    mutationFn: async (id) => { | 
				
			|||
        return await websitesServ.ssl.delete(id) | 
				
			|||
    }, | 
				
			|||
    onSuccess: () => { | 
				
			|||
        message.success(t('message.deleteSuccess', '删除成功')) | 
				
			|||
        get(sslListAtom).refetch() | 
				
			|||
    } | 
				
			|||
})) | 
				
			|||
@ -0,0 +1,11 @@ | 
				
			|||
export interface ICA { | 
				
			|||
    id: number; | 
				
			|||
    createdAt: Date | null; | 
				
			|||
    createdBy: number; | 
				
			|||
    updatedAt: Date | null; | 
				
			|||
    updatedBy: number; | 
				
			|||
    csr: string; | 
				
			|||
    name: string; | 
				
			|||
    privateKey: string; | 
				
			|||
    keyType: string; | 
				
			|||
} | 
				
			|||
@ -0,0 +1,10 @@ | 
				
			|||
export interface IDnsAccount { | 
				
			|||
    id: number; | 
				
			|||
    createdAt: Date | null; | 
				
			|||
    createdBy: number; | 
				
			|||
    updatedAt: Date | null; | 
				
			|||
    updatedBy: number; | 
				
			|||
    name: string; | 
				
			|||
    type: string; | 
				
			|||
    authorization: string; | 
				
			|||
} | 
				
			|||
@ -0,0 +1,39 @@ | 
				
			|||
export interface ISSL { | 
				
			|||
    id: number; | 
				
			|||
    createdAt: Date | null; | 
				
			|||
    createdBy: number; | 
				
			|||
    updatedAt: Date | null; | 
				
			|||
    updatedBy: number; | 
				
			|||
    primaryDomain: string; | 
				
			|||
    privateKey: string; | 
				
			|||
    pem: string; | 
				
			|||
    domains: string; | 
				
			|||
    certUrl: string; | 
				
			|||
    type: string; | 
				
			|||
    provider: string; | 
				
			|||
    organization: string; | 
				
			|||
    dnsAccountId: number; | 
				
			|||
    acmeAccountId: number; | 
				
			|||
    caId: number; | 
				
			|||
    autoRenew: boolean; | 
				
			|||
    expireDate: Date | null; | 
				
			|||
    startDate: Date | null; | 
				
			|||
    status: string; | 
				
			|||
    message: string; | 
				
			|||
    keyType: string; | 
				
			|||
    pushDir: boolean; | 
				
			|||
    dir: string; | 
				
			|||
    description: string; | 
				
			|||
} | 
				
			|||
 | 
				
			|||
 | 
				
			|||
export type ProviderType = 'dnsAccount' | 'dnsManual' | 'http' | 
				
			|||
 | 
				
			|||
 | 
				
			|||
export type SSLSearchParam = { | 
				
			|||
    key?: string | 
				
			|||
    order?: string | 
				
			|||
    prop?: string | 
				
			|||
} | 
				
			|||
 | 
				
			|||
 | 
				
			|||
						Write
						Preview
					
					
					Loading…
					
					Cancel
						Save
					
		Reference in new issue