Browse Source

新增加证书申请页面

main
dark 3 months ago
parent
commit
9e047fc218
  1. 4
      src/App.css
  2. 6
      src/components/action/Action.tsx
  3. 5
      src/components/modal-pro/index.tsx
  4. 5
      src/components/table/style.ts
  5. BIN
      src/pages/websites/cert/assets/google.png
  6. BIN
      src/pages/websites/cert/assets/lets_encrypt.png
  7. BIN
      src/pages/websites/cert/assets/zerossl.png
  8. 394
      src/pages/websites/cert/index.tsx
  9. 26
      src/pages/websites/cert/style.ts
  10. 60
      src/service/websites.ts
  11. 107
      src/store/websites/cert.ts
  12. 20
      src/types/website/cert.d.ts

4
src/App.css

@ -50,6 +50,10 @@
color: green; color: green;
} }
.color-red {
color: #F56C6C;
}
.color-yellow { .color-yellow {
color: rgb(250 145 0) color: rgb(250 145 0)
} }

6
src/components/action/Action.tsx

@ -10,12 +10,12 @@ const Action = ({ title, as, children, ...props }: ActionProps) => {
const { styles } = useStyle() const { styles } = useStyle()
const isLink = as === 'a' || props.type === 'link' const isLink = as === 'a' || props.type === 'link'
const Comp = isLink ? 'a' : Button
return ( return (
<span className={styles.container}> <span className={styles.container}>
<Button {...props}
<Comp {...props}
type={isLink ? 'link' : props.type} type={isLink ? 'link' : props.type}
className={as === 'a' ? styles.actionA : ''}>{title ?? children}</Button>
className={as === 'a' ? styles.actionA : ''}>{title ?? children}</Comp>
</span> </span>
) )
} }

5
src/components/modal-pro/index.tsx

@ -7,10 +7,11 @@ export interface AlterProps extends ModalFuncProps {
onLoad?: () => void onLoad?: () => void
alterType: keyof HookAPI | 'dialog' alterType: keyof HookAPI | 'dialog'
children: JSX.Element | React.ReactNode children: JSX.Element | React.ReactNode
disabled?: boolean
} }
const ModalPro = ({ onLoad, alterType, children, ...props }: AlterProps) => {
const ModalPro = ({ onLoad, alterType, children, disabled, ...props }: AlterProps) => {
const [ modal, modalHolder ] = Modal.useModal() const [ modal, modalHolder ] = Modal.useModal()
const [ dialogRef, dialog, ] = useDialog(props) const [ dialogRef, dialog, ] = useDialog(props)
@ -19,6 +20,8 @@ const ModalPro = ({ onLoad, alterType, children, ...props }: AlterProps) => {
return ( return (
<> <>
<span onClick={() => { <span onClick={() => {
if (disabled) return
if (onLoad) { if (onLoad) {
onLoad() onLoad()
} }

5
src/components/table/style.ts

@ -50,8 +50,7 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any)
const pagination = { const pagination = {
borderTop: '1px solid #ebeef5',
height: '51px',
margin: '0', margin: '0',
alignContent: 'center', alignContent: 'center',
display: 'flex', display: 'flex',
@ -82,6 +81,8 @@ export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any)
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
border-top: 1px solid #ebeef5;
height: 51px;
.ant-pro-table-alert{ .ant-pro-table-alert{
max-width: 50%; max-width: 50%;

BIN
src/pages/websites/cert/assets/google.png

After

Width: 200  |  Height: 67  |  Size: 8.4 KiB

BIN
src/pages/websites/cert/assets/lets_encrypt.png

After

Width: 339  |  Height: 81  |  Size: 3.4 KiB

BIN
src/pages/websites/cert/assets/zerossl.png

After

Width: 373  |  Height: 95  |  Size: 2.8 KiB

394
src/pages/websites/cert/index.tsx

@ -0,0 +1,394 @@
import { useTranslation } from '@/i18n.ts'
import { Button, Form, Popconfirm, Divider, Space, Tooltip, Tag, Input, Progress, Flex } from 'antd'
import { useAtom, useAtomValue } from 'jotai'
import {
algorithmTypes,
bandTypes,
certAtom, certsAtom,
certSearchAtom,
deleteCertAtom,
saveOrUpdateCertAtom, StatusText,
} from '@/store/websites/cert'
import { useEffect, useMemo, useState } from 'react'
import Action from '@/components/action/Action.tsx'
import {
BetaSchemaForm,
ProColumns,
ProFormColumnsType,
} from '@ant-design/pro-components'
import ListPageLayout from '@/layout/ListPageLayout.tsx'
import { useStyle } from './style'
import { CheckCircleFilled, ExclamationCircleFilled, } from '@ant-design/icons'
import { Table as ProTable } from '@/components/table'
import google from './assets/google.png'
import lets_encrypt from './assets/lets_encrypt.png'
import zerossl from './assets/zerossl.png'
import ModalPro from '@/components/modal-pro'
const i18nPrefix = 'cert.list'
const Cert = () => {
const { styles, cx } = useStyle()
const { t } = useTranslation()
const [ form ] = Form.useForm()
const [ filterForm ] = Form.useForm()
const { mutate: saveOrUpdate, isPending: isSubmitting, isSuccess } = useAtomValue(saveOrUpdateCertAtom)
const [ search, setSearch ] = useAtom(certSearchAtom)
const [ currentCert, setCert ] = useAtom(certAtom)
const { data, isFetching, isLoading, refetch } = useAtomValue(certsAtom)
const { mutate: deleteCert, isPending: isDeleting } = useAtomValue(deleteCertAtom)
const [ open, setOpen ] = useState(false)
const [ openFilter, setFilterOpen ] = useState(false)
const [ searchKey, setSearchKey ] = useState(search?.name)
const columns = useMemo(() => {
return [
{
title: 'ID',
dataIndex: 'id',
hideInTable: true,
hideInSearch: true,
formItemProps: { hidden: true }
},
{
title: t(`${i18nPrefix}.columns.domains`, '域名'),
dataIndex: 'domains',
width: 350,
fieldProps: {
style: { width: '100%' },
},
formItemProps: {
rules: [ { required: true, message: t(`${i18nPrefix}.columns.domains.required`, '请输入域名') } ]
},
renderFormItem: (_schema, config) => {
return <Input.TextArea {...config} rows={10} placeholder={`请输入域名,每行一个,支持泛解析域名;如:
*.google.com
*.a.baidu.com
hello.alibaba.com`}/>
}
},
{
title: t(`${i18nPrefix}.columns.type`, '域名验证'),
dataIndex: 'type',
hideInSearch: true,
align: 'center',
render: (_text, record) => {
if (record.type === 4)
return <><CheckCircleFilled className={'color-green'}/></>
return <ExclamationCircleFilled className={'color-red'}/>
}
},
{
title: t(`${i18nPrefix}.columns.brand`, '证书品牌'),
dataIndex: 'brand',
valueType: 'select',
width: 100,
fieldProps: {
style: { width: '100%' },
options: bandTypes
},
render: (_text, record) => {
if (record.brand === 'Google')
return <img src={google} style={{ height: '1rem' }}/>
if (record.brand === 'ZeroSSL')
return <img src={zerossl} style={{ height: '1rem' }}/>
return <img src={lets_encrypt} style={{ height: '1rem' }}/>
},
formItemProps: {
rules: [ { required: true, message: t(`${i18nPrefix}.columns.brand.required`, '请选择证书品牌') } ]
}
},
{
title: t(`${i18nPrefix}.columns.createTime`, '有效期(天)'),
dataIndex: 'createTime',
hideInSearch: true,
hideInForm: true,
width: 220,
render: (_text, record) => {
const content = () => {
if (record.lifeTime)
return <Progress size={'small'} percent={record.remainingTime} format={() => {
return `${record.remainingTime}/${record.lifeTime}`
}}>{record.expire}</Progress>
return <Progress size={'small'} format={() => ``}/>
}
return <Flex style={{ width: 180 }}>
<Tooltip
placement={'right'}
title={<div style={{ fontSize: 12 }}>
<div>{record.notBefore}</div>
<div>{record.notAfter}</div>
<div>{record.createTime}</div>
</div>}
>
{content()}
</Tooltip>
</Flex>
}
},
{
title: t(`${i18nPrefix}.columns.algorithm`, '加密方式'),
dataIndex: 'algorithm',
valueType: 'select',
width: 100,
fieldProps: {
style: { width: '100%' },
options: algorithmTypes,
},
render: (text, record) => {
if (record.algorithm === 'ECC')
return <Tag color={'green'}>{text}</Tag>
return <Tag color={'processing'}>{text}</Tag>
},
formItemProps: {
rules: [ { required: true, message: t(`${i18nPrefix}.columns.algorithm.required`, '请选择加密方式') } ]
}
},
{
title: t(`${i18nPrefix}.columns.status`, '状态'),
dataIndex: 'status',
width: 100,
hideInSearch: true,
hideInForm: true,
render: (_text, record) => {
const [ text, color ] = StatusText[record.status]
return <Tag color={color}>{text} </Tag>
}
},
{
title: t(`${i18nPrefix}.columns.remark`, '备注 '),
dataIndex: 'remark',
},
{
title: t(`${i18nPrefix}.columns.option`, '操作'),
key: 'option',
width: 150,
valueType: 'option',
fixed: 'right',
render: (_, record) => [
<Action key="edit"
as={'a'}
onClick={() => {
form.setFieldsValue(record)
setOpen(true)
}}>{t('actions.edit')}</Action>,
<Divider type={'vertical'}/>,
<ModalPro alterType={'dialog'}
title={t(`${i18nPrefix}.download`, '下载证书')}
disabled={record.status === 3}>
<Action as={'a'}
disabled={record.status === 3} key={'download'}>{t(`actions.download`, '下载')}</Action>
</ModalPro>,
<Divider type={'vertical'}/>,
<Popconfirm
key={'del_confirm'}
disabled={isDeleting}
onConfirm={() => {
deleteCert([ record.id ])
}}
title={t('message.deleteConfirm')}>
<a key="del">
{t('actions.delete', '删除')}
</a>
</Popconfirm>
]
}
] as ProColumns[]
}, [ isDeleting, currentCert, search ])
useEffect(() => {
setSearchKey(search?.name)
filterForm.setFieldsValue(search)
}, [ search ])
useEffect(() => {
if (isSuccess) {
setOpen(false)
}
}, [ isSuccess ])
return (
<ListPageLayout className={styles.container}>
<ProTable
rowKey="id"
headerTitle={
<Button key={'add'}
onClick={() => {
form.resetFields()
form.setFieldsValue({
id: 0,
})
setOpen(true)
}}
type={'primary'}>{t(`${i18nPrefix}.add`, '免费申请证明')}</Button>
}
toolbar={{
search: {
loading: isFetching && !!search?.name,
onSearch: (value: string) => {
setSearch(prev => ({
...prev,
name: value
}))
},
allowClear: true,
onChange: (e) => {
setSearchKey(e.target?.value)
},
value: searchKey,
placeholder: t(`${i18nPrefix}.placeholder`, '输入域名')
},
actions: []
}}/*<Tooltip key={'filter'} title={t(`${i18nPrefix}.filter.tooltip`, '高级查询')}>
<Badge count={getValueCount(search)}>
<Button
onClick={() => {
setFilterOpen(true)
}}
icon={<FilterOutlined/>} shape={'circle'} size={'small'}/>
</Badge>
</Tooltip>,
<Divider type={'vertical'} key={'divider'}/>*/
scroll={{
x: 1100, y: 'calc(100vh - 290px)'
}}
search={false}
onRow={(record) => {
return {
className: cx({
// 'ant-table-row-selected': currentCert?.id === record.id
}),
onClick: () => {
setCert(record)
}
}
}}
dateFormatter="string"
loading={isLoading || isFetching}
dataSource={data?.rows ?? []}
columns={columns}
options={{
reload: () => {
refetch()
},
}}
pagination={{
total: data?.total,
pageSize: search.pageSize,
current: search.page,
onShowSizeChange: (current: number, size: number) => {
setSearch({
...search,
pageSize: size,
page: current
})
},
onChange: (current, pageSize) => {
setSearch(prev => {
return {
...prev,
page: current,
pageSize: pageSize,
}
})
},
}}
/>
<BetaSchemaForm
grid={true}
shouldUpdate={false}
width={1000}
form={form}
layout={'vertical'}
scrollToFirstError={true}
title={t(`${i18nPrefix}.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '账号管理编辑' : '账号管理添加')}
layoutType={'DrawerForm'}
open={open}
drawerProps={{
maskClosable: false,
}}
onOpenChange={(open) => {
setOpen(open)
}}
loading={isSubmitting}
onValuesChange={(values) => {
}}
onFinish={async (values) => {
saveOrUpdate(values)
}}
columns={columns as ProFormColumnsType[]}/>
<BetaSchemaForm
title={t(`${i18nPrefix}.filter.title`, '账号管理高级查询')}
grid={true}
shouldUpdate={false}
width={500}
form={filterForm}
open={openFilter}
onOpenChange={open => {
setFilterOpen(open)
}}
layout={'vertical'}
scrollToFirstError={true}
layoutType={'DrawerForm'}
drawerProps={{
maskClosable: false,
mask: false,
}}
submitter={{
searchConfig: {
resetText: t(`${i18nPrefix}.filter.reset`, '清空'),
submitText: t(`${i18nPrefix}.filter.submit`, '查询'),
},
onReset: () => {
filterForm.resetFields()
},
render: (props,) => {
return (
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => {
props.reset()
}}>{props.searchConfig?.resetText}</Button>
<Button type="primary"
onClick={() => {
props.submit()
}}
>{props.searchConfig?.submitText}</Button>
</Space>
</div>
)
},
}}
onValuesChange={(values) => {
}}
onFinish={async (values) => {
//处理,变成数组
Object.keys(values).forEach(key => {
if (typeof values[key] === 'string' && values[key].includes(',')) {
values[key] = values[key].split(',')
}
})
setSearch(values)
}}
columns={columns.filter(item => !item.hideInSearch) as ProFormColumnsType[]}/>
</ListPageLayout>
)
}
export default Cert

26
src/pages/websites/cert/style.ts

@ -0,0 +1,26 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => {
const prefix = `${prefixCls}-${token?.proPrefix}-domainGroup-list-page`
const container = css`
.ant-table-cell{
.ant-tag{
padding-inline: 3px;
margin-inline-end: 3px;
}
}
.ant-table-empty {
.ant-table-body{
height: calc(100vh - 350px)
}
}
.ant-pro-table-highlight{
}
`
return {
container: cx(prefix, props?.className, container),
}
})

60
src/service/websites.ts

@ -6,6 +6,66 @@ import { IWebsiteDnsRecords } from '@/types/website/record'
import { IWebsiteDnsAccount } from '@/types/website/dns_account' import { IWebsiteDnsAccount } from '@/types/website/dns_account'
const websitesServ = { const websitesServ = {
cert: {
...createCURD<any, ICertificate>('/website/cert'),
list: async (params?: any) => {
console.log(params)
return Promise.resolve({
'code': 0,
'msg': 'success',
'data': {
'total': 3,
'rows': [
{
'id': 10266,
'brand': 'ZeroSSL',
'domains': '*.aa.com,*.abc.com,aa.com,abc.com',
'type': 4,
'level': 1,
'status': 3,
'createTime': '2024-06-29 14:36:02',
'remark': '',
'algorithm': 'ECC',
'fromType': 2
},
{
'id': 10265,
'brand': 'ZeroSSL',
'domains': '*.aa.com,*.abc.com,aa.com,abc.com',
'type': 4,
'level': 1,
'status': 3,
'createTime': '2024-06-29 14:35:58',
'remark': '',
'algorithm': 'ECC',
'fromType': 2
},
{
'id': 10261,
'brand': 'Google',
'domains': '*.3456.world,3456.world',
'type': 3,
'level': 1,
'status': 1,
'createTime': '2024-06-29 14:17:46',
'notAfter': '2024-09-27 13:18:31',
'notBefore': '2024-06-29 13:18:32',
'version': 3,
'sigAlgName': 'SHA256withRSA',
'remark': '3456.world',
'name': '*.3456.world',
'lifeTime': 90,
'remainingTime': 82,
'algorithm': 'RSA',
'bit': 2048,
'fromType': 2
}
]
},
})
// return request.post<any, any>('/website/cert/list', params)
}
},
ssl: { ssl: {
...createCURD<any, WebSite.ISSL>('/website/ssl'), ...createCURD<any, WebSite.ISSL>('/website/ssl'),
upload: async (params: WebSite.SSLUploadDto) => { upload: async (params: WebSite.SSLUploadDto) => {

107
src/store/websites/cert.ts

@ -0,0 +1,107 @@
import { atom } from 'jotai'
import { IApiResult, IPage } from '@/global'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { message } from 'antd'
import { t } from 'i18next'
import websitesServ from '@/service/websites.ts'
type SearchParams = IPage & {
name?: string
}
export const bandTypes = [
{ label: 'Google', value: 'Google' },
{ label: 'ZeroSSL', value: 'ZeroSSL' },
{ label: 'Let\'s Encrypt', value: 'Let\'s Encrypt' },
]
export const algorithmTypes = [
{ label: 'RSA', value: 'RSA' },
{ label: 'ECC', value: 'ECC' },
]
export const StatusText = {
1: [ '已签发', 'green' ],
2: [ '申请中', 'default' ],
3: [ '申请失败', 'red' ]
}
export const certIdAtom = atom(0)
export const certIdsAtom = atom<number[]>([])
export const certAtom = atom<ICertificate>(undefined as unknown as ICertificate)
export const certSearchAtom = atom<SearchParams>({
// key: '',
pageSize: 10,
page: 1,
} as SearchParams)
export const certPageAtom = atom<IPage>({
pageSize: 10,
page: 1,
})
export const certsAtom = atomWithQuery((get) => {
return {
queryKey: [ 'certs', get(certSearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
return await websitesServ.cert.list(params as SearchParams)
},
select: res => {
const data = res.data
data.rows = data.rows?.map(row => {
return {
...row,
//status: convertToBool(row.status)
}
})
return data
}
}
})
//saveOrUpdateAtom
export const saveOrUpdateCertAtom = atomWithMutation<IApiResult, ICertificate>((get) => {
return {
mutationKey: [ 'updateCert' ],
mutationFn: async (data) => {
//data.status = data.status ? '1' : '0'
if (data.id === 0) {
return await websitesServ.cert.add(data)
}
return await websitesServ.cert.update(data)
},
onSuccess: (res) => {
const isAdd = !!res.data?.id
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功'))
//更新列表
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore fix
get(queryClientAtom).invalidateQueries({ queryKey: [ 'certs', get(certSearchAtom) ] })
return res
}
}
})
export const deleteCertAtom = atomWithMutation((get) => {
return {
mutationKey: [ 'deleteCert' ],
mutationFn: async (ids: number[]) => {
return await websitesServ.cert.batchDelete(ids ?? get(certIdsAtom))
},
onSuccess: (res) => {
message.success('message.deleteSuccess')
//更新列表
get(queryClientAtom).invalidateQueries({ queryKey: [ 'certs', get(certSearchAtom) ] })
return res
}
}
})

20
src/types/website/cert.d.ts

@ -0,0 +1,20 @@
interface ICertificate {
id: number;
brand: string;
domains: string;
type: number;
level: number;
status: number;
createTime: string;
notAfter: string;
notBefore: string;
version: number;
sigAlgName: string;
remark: string;
name: string;
lifeTime: number;
remainingTime: number;
algorithm: string;
bit: number;
fromType: number;
}
Loading…
Cancel
Save