dark
5 months ago
12 changed files with 625 additions and 10 deletions
-
4src/App.css
-
6src/components/action/Action.tsx
-
5src/components/modal-pro/index.tsx
-
5src/components/table/style.ts
-
BINsrc/pages/websites/cert/assets/google.png
-
BINsrc/pages/websites/cert/assets/lets_encrypt.png
-
BINsrc/pages/websites/cert/assets/zerossl.png
-
394src/pages/websites/cert/index.tsx
-
26src/pages/websites/cert/style.ts
-
60src/service/websites.ts
-
107src/store/websites/cert.ts
-
20src/types/website/cert.d.ts
After Width: 200 | Height: 67 | Size: 8.4 KiB |
After Width: 339 | Height: 81 | Size: 3.4 KiB |
After Width: 373 | Height: 95 | Size: 2.8 KiB |
@ -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 |
@ -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), |
|||
} |
|||
}) |
@ -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 |
|||
} |
|||
} |
|||
}) |
@ -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; |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue