dark
7 months ago
22 changed files with 693 additions and 149 deletions
-
1package.json
-
23src/components/action/Action.tsx
-
1src/components/action/index.ts
-
17src/components/action/style.ts
-
54src/components/drawer-picker/DrawerPicker.tsx
-
59src/components/status/Status.tsx
-
1src/components/status/index.ts
-
1src/locales/lang/en-US.ts
-
41src/locales/lang/status/zh-CN.ts
-
157src/locales/lang/zh-CN.ts
-
11src/pages/websites/ssl/ca/Detail.tsx
-
127src/pages/websites/ssl/components/Detail.tsx
-
77src/pages/websites/ssl/components/Upload.tsx
-
23src/pages/websites/ssl/components/store.ts
-
23src/pages/websites/ssl/components/style.ts
-
115src/pages/websites/ssl/index.tsx
-
7src/service/websites.ts
-
14src/store/websites/ssl.ts
-
2src/types/index.d.ts
-
10src/types/website/ssl.d.ts
-
73vite.config.ts
-
5yarn.lock
@ -0,0 +1,23 @@ |
|||
import { Button, ButtonProps } from 'antd' |
|||
import { useStyle } from './style' |
|||
|
|||
export interface ActionProps extends ButtonProps { |
|||
as?: string |
|||
} |
|||
|
|||
const Action = ({ title, as, children, ...props }: ActionProps) => { |
|||
|
|||
const { styles } = useStyle() |
|||
|
|||
const isLink = as === 'a' || props.type === 'link' |
|||
|
|||
return ( |
|||
<span className={styles.container}> |
|||
<Button {...props} |
|||
type={isLink ? 'link' : props.type} |
|||
className={as === 'a' ? styles.actionA : ''}>{title ?? children}</Button> |
|||
</span> |
|||
) |
|||
} |
|||
|
|||
export default Action |
@ -0,0 +1 @@ |
|||
export * from './Action' |
@ -0,0 +1,17 @@ |
|||
import { createStyles } from '@/theme' |
|||
|
|||
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => { |
|||
const prefix = `${prefixCls}-${token?.proPrefix}-action` |
|||
|
|||
const container = css`
|
|||
|
|||
`
|
|||
const actionA = css`
|
|||
padding: 0 2px; |
|||
`
|
|||
|
|||
return { |
|||
container: cx(prefix, props?.className, container), |
|||
actionA, |
|||
} |
|||
}) |
@ -1,42 +1,60 @@ |
|||
import { Button, Drawer, DrawerProps } from 'antd' |
|||
import React, { useState } from 'react' |
|||
import React, { forwardRef, useState, useImperativeHandle, memo } from 'react' |
|||
import { useStyle } from './style' |
|||
import { generateUUID } from '@/utils/uuid.ts' |
|||
|
|||
export interface DrawerPickerProps extends DrawerProps { |
|||
target?: React.ReactNode |
|||
target?: React.ReactNode | boolean |
|||
children?: React.ReactNode |
|||
key?: string |
|||
id?: string |
|||
foreRender?: boolean |
|||
} |
|||
|
|||
const DrawerPicker = ({ children, target, foreRender, ...props }: DrawerPickerProps) => { |
|||
export interface DrawerPickerRef { |
|||
open: () => void, |
|||
close: () => void |
|||
} |
|||
|
|||
const DrawerPicker = forwardRef<DrawerPickerRef | undefined, DrawerPickerProps>(( |
|||
{ |
|||
children, |
|||
target, |
|||
foreRender, |
|||
...props |
|||
}: DrawerPickerProps, ref) => { |
|||
|
|||
const { styles, cx } = useStyle() |
|||
|
|||
const { styles } = useStyle() |
|||
const [ open, setOpen ] = useState(() => false) |
|||
|
|||
const [ open, setOpen ] = useState(false) |
|||
useImperativeHandle(ref, () => ({ |
|||
open: () => { |
|||
setOpen(true) |
|||
}, |
|||
close: () => { |
|||
setOpen(false) |
|||
} |
|||
}), []) |
|||
|
|||
const getTarget = () => { |
|||
if (target === false) return null |
|||
const def = <Button>{props.title ?? 'Target'}</Button> |
|||
return <span className={styles.target}>{target ?? def}</span> |
|||
return <div className={styles.target} onClick={() => { |
|||
setOpen(true) |
|||
}}> |
|||
<span className={styles.target}>{target ?? def}</span> |
|||
</div> |
|||
} |
|||
|
|||
return ( |
|||
<div className={styles.container} key={props.key ?? generateUUID()}> |
|||
<div className={styles.target} onClick={() => { |
|||
setOpen(true) |
|||
}}> |
|||
{getTarget()} |
|||
</div> |
|||
<div className={cx(styles.container, props.id)}> |
|||
{getTarget()} |
|||
{ |
|||
(foreRender || open) && <Drawer {...props} |
|||
open={open} |
|||
onClose={() => setOpen(false)} |
|||
>{children}</Drawer> |
|||
} |
|||
|
|||
</div> |
|||
) |
|||
} |
|||
}) |
|||
|
|||
export default DrawerPicker |
|||
export default memo(DrawerPicker) |
@ -0,0 +1,59 @@ |
|||
import { Tag, TagProps } from 'antd' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { SyncOutlined } from '@ant-design/icons' |
|||
|
|||
export interface StatusProps extends TagProps { |
|||
status: string |
|||
} |
|||
|
|||
|
|||
const getColor = (status: string) => { |
|||
if (status.includes('error') || status.includes('err')) { |
|||
return 'danger' |
|||
} |
|||
switch (status) { |
|||
case 'running': |
|||
return 'success' |
|||
case 'stopped': |
|||
return 'danger' |
|||
case 'unhealthy': |
|||
case 'paused': |
|||
case 'exited': |
|||
case 'dead': |
|||
case 'removing': |
|||
return 'warning' |
|||
default: |
|||
return 'primary' |
|||
} |
|||
} |
|||
|
|||
const loadingStatus = [ |
|||
'installing', |
|||
'building', |
|||
'restarting', |
|||
'upgrading', |
|||
'rebuilding', |
|||
'recreating', |
|||
'creating', |
|||
'starting', |
|||
'removing', |
|||
'applying', |
|||
] |
|||
|
|||
const loadingIcon = (status: string): boolean => { |
|||
return loadingStatus.indexOf(status) > -1 |
|||
} |
|||
export const Status = ({ status = 'running', ...props }: StatusProps) => { |
|||
|
|||
const { t } = useTranslation() |
|||
const icon = loadingIcon(status) ? <SyncOutlined spin/> : null |
|||
return ( |
|||
<> |
|||
<Tag {...props} |
|||
color={getColor(status)} |
|||
icon={icon}>{t(`status.${status}`, status)}</Tag> |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default Status |
@ -0,0 +1 @@ |
|||
export * from './Status.tsx' |
@ -0,0 +1,41 @@ |
|||
export default { |
|||
running: '已启动', |
|||
done: '已完成', |
|||
success: '成功', |
|||
waiting: '执行中', |
|||
waiting1: '等待中', |
|||
failed: '失败', |
|||
stopped: '已停止', |
|||
error: '失败', |
|||
created: '已创建', |
|||
restarting: '重启中', |
|||
uploading: '上传中', |
|||
unhealthy: '异常', |
|||
removing: '移除中', |
|||
paused: '已暂停', |
|||
exited: '已停止', |
|||
dead: '已结束', |
|||
installing: '安装中', |
|||
enabled: '已启用', |
|||
disabled: '已停止', |
|||
normal: '正常', |
|||
building: '制作镜像中', |
|||
downloaderr: '下载失败', |
|||
upgrading: '升级中', |
|||
upgradeerr: '升级失败', |
|||
pullerr: '镜像拉取失败', |
|||
rebuilding: '重建中', |
|||
deny: '已屏蔽', |
|||
accept: '已放行', |
|||
used: '已使用', |
|||
unUsed: '未使用', |
|||
starting: '启动中', |
|||
recreating: '重建中', |
|||
creating: '创建中', |
|||
systemrestart: '中断', |
|||
init: '等待申请', |
|||
ready: '正常', |
|||
applying: '申请中', |
|||
applyerror: '失败', |
|||
syncerr: '失败', |
|||
} |
@ -1,87 +1,90 @@ |
|||
import antdZh from 'antd/locale/zh_CN' |
|||
import menus from './pages/system/menus/zh-CN.ts' |
|||
import roles from './pages/system/roles/zh-CN.ts' |
|||
import status from './status/zh-CN.ts' |
|||
|
|||
export default { |
|||
...antdZh, |
|||
error: { |
|||
'404': { |
|||
title: '无法找到', |
|||
message: '找不到此页面' |
|||
...antdZh, |
|||
status, |
|||
error: { |
|||
'404': { |
|||
title: '无法找到', |
|||
message: '找不到此页面' |
|||
}, |
|||
'403': { |
|||
title: '没有权限', |
|||
message: '对不起,您没有权限查看此页面。' |
|||
}, |
|||
'error': { |
|||
title: '错误信息', |
|||
}, |
|||
}, |
|||
'403': { |
|||
title: '没有权限', |
|||
message: '对不起,您没有权限查看此页面。' |
|||
route: { |
|||
goBack: '返回', |
|||
}, |
|||
'error': { |
|||
title: '错误信息', |
|||
app: { |
|||
header: { |
|||
logout: '退出登录', |
|||
} |
|||
}, |
|||
login: { |
|||
title: '账户密码登录', |
|||
username: '用户名', |
|||
usernameMsg: '请输入用户名', |
|||
password: '密码', |
|||
passwordMsg: '请输入密码', |
|||
code: '验证码', |
|||
codeMsg: '请输入验证码', |
|||
submit: '登录', |
|||
success: '登录成功' |
|||
}, |
|||
home: { |
|||
welcome: '欢迎使用' |
|||
}, |
|||
}, |
|||
route: { |
|||
goBack: '返回', |
|||
}, |
|||
app: { |
|||
header: { |
|||
logout: '退出登录', |
|||
} |
|||
}, |
|||
login: { |
|||
title: '账户密码登录', |
|||
username: '用户名', |
|||
usernameMsg: '请输入用户名', |
|||
password: '密码', |
|||
passwordMsg: '请输入密码', |
|||
code: '验证码', |
|||
codeMsg: '请输入验证码', |
|||
submit: '登录', |
|||
success: '登录成功' |
|||
}, |
|||
home: { |
|||
welcome: '欢迎使用' |
|||
}, |
|||
|
|||
system: { |
|||
menus, |
|||
roles |
|||
}, |
|||
actions: { |
|||
news: '新增', |
|||
add: '添加', |
|||
edit: '编辑', |
|||
cancel: '取消', |
|||
delete: '删除', |
|||
batchDel: '批量删除', |
|||
reset: '重置', |
|||
clear: '清空', |
|||
close: '关闭', |
|||
}, |
|||
message: { |
|||
infoTitle: '提示', |
|||
errorTitle: '错误', |
|||
successTitle: '成功', |
|||
warningTitle: '警告', |
|||
batchDelete: '确定要删除所选数据吗?', |
|||
deleteConfirm: '确定要删除吗?', |
|||
success: '提交成功', |
|||
fail: '提交失败', |
|||
saveSuccess: '保存成功', |
|||
editSuccess: '修改成功', |
|||
deleteSuccess: '删除成功', |
|||
saveFail: '保存失败', |
|||
emptyData: '暂无数据', |
|||
emptyDataAdd: '暂无数据,点击添加', |
|||
required: '此项为必填项', |
|||
}, |
|||
rules: { |
|||
required: '此项为必填项', |
|||
}, |
|||
tabs: { |
|||
refresh: '刷新', |
|||
maximize: '最大化', |
|||
closeCurrent: '关闭当前', |
|||
closeLeft: '关闭左侧', |
|||
closeRight: '关闭右侧', |
|||
closeOther: '关闭其它', |
|||
closeAll: '关闭所有' |
|||
} |
|||
system: { |
|||
menus, |
|||
roles |
|||
}, |
|||
actions: { |
|||
news: '新增', |
|||
add: '添加', |
|||
edit: '编辑', |
|||
ok: '确定', |
|||
cancel: '取消', |
|||
delete: '删除', |
|||
batchDel: '批量删除', |
|||
reset: '重置', |
|||
clear: '清空', |
|||
close: '关闭', |
|||
}, |
|||
message: { |
|||
infoTitle: '提示', |
|||
errorTitle: '错误', |
|||
successTitle: '成功', |
|||
warningTitle: '警告', |
|||
batchDelete: '确定要删除所选数据吗?', |
|||
deleteConfirm: '确定要删除吗?', |
|||
success: '提交成功', |
|||
fail: '提交失败', |
|||
saveSuccess: '保存成功', |
|||
editSuccess: '修改成功', |
|||
deleteSuccess: '删除成功', |
|||
saveFail: '保存失败', |
|||
emptyData: '暂无数据', |
|||
emptyDataAdd: '暂无数据,点击添加', |
|||
required: '此项为必填项', |
|||
}, |
|||
rules: { |
|||
required: '此项为必填项', |
|||
}, |
|||
tabs: { |
|||
refresh: '刷新', |
|||
maximize: '最大化', |
|||
closeCurrent: '关闭当前', |
|||
closeLeft: '关闭左侧', |
|||
closeRight: '关闭右侧', |
|||
closeOther: '关闭其它', |
|||
closeAll: '关闭所有' |
|||
} |
|||
} |
@ -0,0 +1,127 @@ |
|||
import { Segmented, Drawer, DrawerProps, Input, Button, Flex, message, Descriptions } from 'antd' |
|||
import { useEffect, useMemo, useState } from 'react' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { useAtom } from 'jotai' |
|||
import { detailAtom } from './store.ts' |
|||
import { useStyle } from './style.ts' |
|||
import { useCopyToClipboard } from 'react-use' |
|||
import { getProvider } from '@/store/websites/ssl.ts' |
|||
|
|||
const Detail = (props: DrawerProps) => { |
|||
|
|||
|
|||
const prefix = 'website.ssl.detail' |
|||
const { t } = useTranslation() |
|||
const { styles } = useStyle() |
|||
const [ key, setKey ] = useState('base') |
|||
const [ ui, setUI ] = useAtom(detailAtom) |
|||
const [ copyState, setCopy ] = useCopyToClipboard() |
|||
|
|||
const options = useMemo(() => { |
|||
return [ |
|||
{ label: t(`${prefix}.base`, '证书信息'), value: 'base' }, |
|||
{ label: t(`${prefix}.pem`, '证书'), value: 'pem' }, |
|||
{ label: t(`${prefix}.private_key`, '私钥'), value: 'private_key' }, |
|||
] |
|||
}, []) |
|||
|
|||
useEffect(() => { |
|||
if (copyState.error) { |
|||
message.error(t('message.copyError', '复制失败')) |
|||
} else if (copyState.value) { |
|||
message.success(t('message.copySuccess', '复制成功')) |
|||
} |
|||
}, [ copyState ]) |
|||
|
|||
useEffect(() => { |
|||
return () => { |
|||
setUI({ |
|||
open: false, |
|||
record: {}, |
|||
}) |
|||
} |
|||
}, []) |
|||
|
|||
const render = useMemo(() => { |
|||
switch (key) { |
|||
case 'base': |
|||
return <div> |
|||
<Descriptions bordered={true} column={1}> |
|||
<Descriptions.Item label={t(`${prefix}.primary_domain`, '主域名')}> |
|||
{ui.record?.primary_domain} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.domains`, '其它域名')}> |
|||
{ui.record?.domains} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.type`, '证书主体名称(CN)')}> |
|||
{ui.record?.type} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.organization`, '颁发组织')}> |
|||
{ui.record?.organization} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.start_date`, '生效时间')}> |
|||
{ui.record?.start_date} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.expire_date`, '过期时间')}> |
|||
{ui.record?.expire_date} |
|||
</Descriptions.Item> |
|||
<Descriptions.Item label={t(`${prefix}.provider`, '申请方式')}> |
|||
{getProvider(ui.record?.provider)} |
|||
</Descriptions.Item> |
|||
</Descriptions> |
|||
</div> |
|||
case 'pem': |
|||
return <Flex gap={15} vertical={true}> |
|||
<Input.TextArea |
|||
rows={20} |
|||
value={ui.record?.pem} |
|||
/> |
|||
<span> |
|||
<Button type={'primary'} onClick={() => { |
|||
setCopy(ui.record?.pem) |
|||
}}> {t('actions.copy', '复制')}</Button> |
|||
</span> |
|||
</Flex> |
|||
case 'private_key': |
|||
return <Flex gap={15} vertical={true}> |
|||
<Input.TextArea |
|||
rows={20} |
|||
value={ui.record?.private_key} |
|||
/> |
|||
<span> |
|||
<Button type={'primary'} onClick={() => { |
|||
setCopy(ui.record?.private_key) |
|||
}}> {t('actions.copy', '复制')}</Button> |
|||
</span> |
|||
</Flex> |
|||
default: |
|||
return null |
|||
} |
|||
}, [ key, ui.record ]) |
|||
|
|||
return ( |
|||
<Drawer {...props} |
|||
destroyOnClose={true} |
|||
title={t(`${prefix}.title`, '详情')} |
|||
width={800} |
|||
open={ui.open} |
|||
onClose={() => |
|||
setUI(prev => ({ |
|||
...prev, |
|||
open: false |
|||
})) |
|||
} |
|||
> |
|||
<Segmented |
|||
value={key} |
|||
options={options} |
|||
onChange={setKey} |
|||
/> |
|||
<div className={styles.content}> |
|||
{render} |
|||
</div> |
|||
</Drawer> |
|||
) |
|||
} |
|||
|
|||
export default Detail |
@ -0,0 +1,77 @@ |
|||
import { Select, Form, Input } from 'antd' |
|||
import { useTranslation } from '@/i18n.ts' |
|||
import { useAtom } from 'jotai' |
|||
import { uploadAtom, uploadType } from './store.ts' |
|||
import { Else, If, Then } from 'react-if' |
|||
import { MutableRefObject, useEffect } from 'react' |
|||
import { FormInstance } from 'antd/lib' |
|||
|
|||
export interface UploadProps { |
|||
form?: FormInstance |
|||
formRef?: MutableRefObject<FormInstance | undefined> |
|||
} |
|||
|
|||
const Upload = (props: UploadProps) => { |
|||
const { t } = useTranslation() |
|||
const prefix = 'website.ssl.upload' |
|||
const [ form ] = Form.useForm() |
|||
const [ dto, updateDto ] = useAtom(uploadAtom) |
|||
|
|||
useEffect(() => { |
|||
form.setFieldsValue(dto) |
|||
}, []) |
|||
|
|||
props.formRef!.current = props.form ?? form |
|||
|
|||
return ( |
|||
<Form |
|||
form={props.form ?? form} |
|||
layout={'vertical'} |
|||
onValuesChange={(_change) => { |
|||
updateDto(prev => ({ |
|||
...prev, |
|||
..._change |
|||
})) |
|||
}} |
|||
scrollToFirstError={true} |
|||
> |
|||
<Form.Item name={'type'} label={t(`${prefix}.type`, '导入方式')} |
|||
rules={[ { required: true } ]} |
|||
> |
|||
<Select options={uploadType}/> |
|||
</Form.Item> |
|||
|
|||
<If condition={dto.type === 'paste'}> |
|||
<Then> |
|||
<Form.Item name={'private_key'} label={t(`${prefix}.private_key`, '私钥(KEY)')} |
|||
rules={[ { required: true } ]} |
|||
> |
|||
<Input.TextArea rows={10}/> |
|||
</Form.Item> |
|||
<Form.Item name={'pem'} label={t(`${prefix}.pem`, '证书(PEM格式)')} |
|||
rules={[ { required: true } ]} |
|||
> |
|||
<Input.TextArea rows={10}/> |
|||
</Form.Item> |
|||
</Then> |
|||
<Else> |
|||
<Form.Item name={'private_key_path'} label={t(`${prefix}.private_key_path`, '私钥文件')} |
|||
rules={[ { required: true } ]} |
|||
> |
|||
<Input/> |
|||
</Form.Item> <Form.Item name={'pem_path'} label={t(`${prefix}.pem_path`, '证书文件')} |
|||
rules={[ { required: true } ]} |
|||
> |
|||
<Input/> |
|||
</Form.Item> |
|||
</Else> |
|||
</If> |
|||
|
|||
<Form.Item name={'description'} label={t(`${prefix}.description`, '备注')}> |
|||
<Input/> |
|||
</Form.Item> |
|||
</Form> |
|||
) |
|||
} |
|||
|
|||
export default Upload |
@ -0,0 +1,23 @@ |
|||
import { atom } from 'jotai' |
|||
import { WebSite } from '@/types' |
|||
import { t } from 'i18next' |
|||
|
|||
type DetailUI = { |
|||
open: boolean, |
|||
record?: WebSite.ISSL, |
|||
|
|||
} |
|||
|
|||
export const detailAtom = atom<DetailUI>({ |
|||
open: false, |
|||
}) |
|||
|
|||
export const uploadType = [ |
|||
{ label: t('website.ssl.upload.type_paste', '粘贴代码'), value: 'paste' }, |
|||
{ label: t('website.ssl.upload.type_local', '选择服务器文件'), value: 'local' }, |
|||
] |
|||
|
|||
|
|||
export const uploadAtom = atom<WebSite.SSLUploadDto>({ |
|||
type: 'paste' |
|||
}) |
@ -0,0 +1,23 @@ |
|||
import { createStyles } from '@/theme' |
|||
|
|||
export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => { |
|||
const prefix = `${prefixCls}-${token?.proPrefix}-ssl-detail` |
|||
|
|||
const container = css`
|
|||
|
|||
`
|
|||
|
|||
const content = css`
|
|||
padding: 20px 0; |
|||
|
|||
.ant-descriptions-item-label{ |
|||
font-weight: bold; |
|||
width: 50%; |
|||
} |
|||
`
|
|||
|
|||
return { |
|||
container: cx(prefix, props?.className, container), |
|||
content, |
|||
} |
|||
}) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue