添加订阅管理前端

This commit is contained in:
whyour 2022-05-08 09:41:06 +08:00
parent d27ca56a2d
commit f9e2e22b85
10 changed files with 590 additions and 22 deletions

View File

@ -1,14 +1,12 @@
import expressLoader from './express'; import expressLoader from './express';
import dependencyInjectorLoader from './dependencyInjector'; import depInjectorLoader from './depInjector';
import Logger from './logger'; import Logger from './logger';
import initData from './initData'; import initData from './initData';
import { Application } from 'express'; import { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
export default async ({ expressApp }: { expressApp: Application }) => { export default async ({ expressApp }: { expressApp: Application }) => {
await dependencyInjectorLoader({ await depInjectorLoader();
models: [],
});
Logger.info('✌️ Dependency Injector loaded'); Logger.info('✌️ Dependency Injector loaded');
await expressLoader({ app: expressApp }); await expressLoader({ app: expressApp });

View File

@ -1,12 +1,8 @@
import { Container } from 'typedi'; import { Container } from 'typedi';
import LoggerInstance from './logger'; import LoggerInstance from './logger';
export default ({ models }: { models: { name: string; model: any }[] }) => { export default () => {
try { try {
models.forEach((m) => {
Container.set(m.name, m.model);
});
Container.set('logger', LoggerInstance); Container.set('logger', LoggerInstance);
} catch (e) { } catch (e) {
LoggerInstance.error('🔥 Error on dependency injector loader: %o', e); LoggerInstance.error('🔥 Error on dependency injector loader: %o', e);

View File

@ -11,7 +11,7 @@
"schedule": "npm run build:back && node static/build/schedule.js", "schedule": "npm run build:back && node static/build/schedule.js",
"public": "npm run build:back && node static/build/public.js", "public": "npm run build:back && node static/build/public.js",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"prepare": "umi generate tmp 2>/dev/null || true", "postinstall": "umi generate tmp 2>/dev/null || true",
"test": "umi-test", "test": "umi-test",
"test:coverage": "umi-test --coverage" "test:coverage": "umi-test --coverage"
}, },

View File

@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons'; import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({ const IconFont = createFromIconfontCN({
scriptUrl: ['//at.alicdn.com/t/font_3354854_pk18p04ny1a.js'], scriptUrl: ['//at.alicdn.com/t/font_3354854_ds8pa06q1qa.js'],
}); });
export default IconFont; export default IconFont;

View File

@ -9,6 +9,7 @@ import {
ControlOutlined, ControlOutlined,
ContainerOutlined, ContainerOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import IconFont from '@/components/iconfont';
export default { export default {
route: { route: {
@ -34,43 +35,49 @@ export default {
{ {
path: '/crontab', path: '/crontab',
name: '定时任务', name: '定时任务',
icon: <FieldTimeOutlined />, icon: <IconFont type="ql-icon-crontab" />,
component: '@/pages/crontab/index', component: '@/pages/crontab/index',
}, },
{
path: '/subscription',
name: '订阅管理',
icon: <IconFont type="ql-icon-subs" />,
component: '@/pages/subscription/index',
},
{ {
path: '/env', path: '/env',
name: '环境变量', name: '环境变量',
icon: <RadiusSettingOutlined />, icon: <IconFont type="ql-icon-env" />,
component: '@/pages/env/index', component: '@/pages/env/index',
}, },
{ {
path: '/config', path: '/config',
name: '配置文件', name: '配置文件',
icon: <ControlOutlined />, icon: <IconFont type="ql-icon-config" />,
component: '@/pages/config/index', component: '@/pages/config/index',
}, },
{ {
path: '/script', path: '/script',
name: '脚本管理', name: '脚本管理',
icon: <FormOutlined />, icon: <IconFont type="ql-icon-script" />,
component: '@/pages/script/index', component: '@/pages/script/index',
}, },
{ {
path: '/dependence', path: '/dependence',
name: '依赖管理', name: '依赖管理',
icon: <ContainerOutlined />, icon: <IconFont type="ql-icon-dependence" />,
component: '@/pages/dependence/index', component: '@/pages/dependence/index',
}, },
{ {
path: '/diff', path: '/diff',
name: '对比工具', name: '对比工具',
icon: <DiffOutlined />, icon: <IconFont type="ql-icon-diff" />,
component: '@/pages/diff/index', component: '@/pages/diff/index',
}, },
{ {
path: '/log', path: '/log',
name: '任务日志', name: '任务日志',
icon: <FolderOutlined />, icon: <IconFont type="ql-icon-log" />,
component: '@/pages/log/index', component: '@/pages/log/index',
}, },
{ {

View File

@ -396,8 +396,8 @@ const CronDetailModal = ({
<IconFont <IconFont
type={ type={
currentCron.isDisabled === 1 currentCron.isDisabled === 1
? 'ql-icon-qiyong' ? 'ql-icon-enable'
: 'ql-icon-jinyong' : 'ql-icon-disable'
} }
/> />
} }
@ -412,8 +412,8 @@ const CronDetailModal = ({
<IconFont <IconFont
type={ type={
currentCron.isPinned === 1 currentCron.isPinned === 1
? 'ql-icon-quxiaozhiding' ? 'ql-icon-untop'
: 'ql-icon-zhiding' : 'ql-icon-top'
} }
/> />
} }

View File

View File

@ -0,0 +1,445 @@
import React, { useState, useEffect } from 'react';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Dropdown,
Menu,
Typography,
Input,
} from 'antd';
import {
ClockCircleOutlined,
Loading3QuartersOutlined,
CloseCircleOutlined,
EllipsisOutlined,
CheckCircleOutlined,
EditOutlined,
StopOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
import SubscriptionModal from './modal';
import { getTableScroll } from '@/utils/index';
import { history } from 'umi';
import './index.less';
const { Text, Paragraph } = Typography;
const { Search } = Input;
export enum SubscriptionStatus {
'running',
'idle',
'disabled',
'queued',
}
const Subscription = ({ headerStyle, isPhone, theme }: any) => {
const columns: any = [
{
title: '订阅名',
dataIndex: 'name',
key: 'name',
width: 150,
align: 'center' as const,
sorter: {
compare: (a: any, b: any) => a.name.localeCompare(b.name),
multiple: 2,
},
},
{
title: '订阅',
dataIndex: 'command',
key: 'command',
width: 250,
align: 'center' as const,
render: (text: string, record: any) => {
return (
<Paragraph
style={{
wordBreak: 'break-all',
marginBottom: 0,
textAlign: 'left',
}}
ellipsis={{ tooltip: text, rows: 2 }}
>
{text}
</Paragraph>
);
},
sorter: {
compare: (a: any, b: any) => a.command.localeCompare(b.command),
multiple: 3,
},
},
{
title: '订阅定时',
dataIndex: 'schedule',
key: 'schedule',
width: 110,
align: 'center' as const,
sorter: {
compare: (a: any, b: any) => a.schedule.localeCompare(b.schedule),
multiple: 1,
},
},
{
title: '状态',
key: 'status',
dataIndex: 'status',
align: 'center' as const,
width: 85,
filters: [
{
text: '运行中',
value: 0,
},
{
text: '空闲中',
value: 1,
},
{
text: '已禁用',
value: 2,
},
],
onFilter: (value: number, record: any) => {
if (record.isDisabled && record.status !== 0) {
return value === 2;
} else {
return record.status === value;
}
},
render: (text: string, record: any) => (
<>
{(!record.isDisabled ||
record.status !== SubscriptionStatus.idle) && (
<>
{record.status === SubscriptionStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default">
</Tag>
)}
{record.status === SubscriptionStatus.running && (
<Tag
icon={<Loading3QuartersOutlined spin />}
color="processing"
>
</Tag>
)}
</>
)}
{record.isDisabled === 1 &&
record.status === SubscriptionStatus.idle && (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</>
),
},
{
title: '操作',
key: 'action',
align: 'center' as const,
width: 100,
render: (text: string, record: any, index: number) => {
return (
<Space size="middle">
<MoreBtn key="more" record={record} index={index} />
</Space>
);
},
},
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedSubscription, setEditedSubscription] = useState();
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [tableScrollHeight, setTableScrollHeight] = useState<number>();
const [searchValue, setSearchValue] = useState('');
const goToScriptManager = (record: any) => {
const cmd = record.command.split(' ') as string[];
if (cmd[0] === 'task') {
if (cmd[1].startsWith('/ql/data/scripts')) {
cmd[1] = cmd[1].replace('/ql/data/scripts/', '');
}
let [p, s] = cmd[1].split('/');
if (!s) {
s = p;
p = '';
}
history.push(`/script?p=${p}&s=${s}`);
} else if (cmd[1] === 'repo') {
location.href = cmd[2];
}
};
const getSubscriptions = () => {
setLoading(true);
request
.get(`${config.apiPrefix}subscriptions?searchValue=${searchText}`)
.then((data: any) => {
setValue(data.data);
setCurrentPage(1);
})
.finally(() => setLoading(false));
};
const addSubscription = () => {
setEditedSubscription(null as any);
setIsModalVisible(true);
};
const editSubscription = (record: any, index: number) => {
setEditedSubscription(record);
setIsModalVisible(true);
};
const delSubscription = (record: any, index: number) => {
Modal.confirm({
title: '确认删除',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.delete(`${config.apiPrefix}subscriptions`, { data: [record.id] })
.then((data: any) => {
if (data.code === 200) {
message.success('删除成功');
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1);
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const enabledOrDisabledSubscription = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isDisabled === 1 ? '启用' : '禁用'}`,
content: (
<>
{record.isDisabled === 1 ? '启用' : '禁用'}
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}subscriptions/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`,
{
data: [record.id],
},
)
.then((data: any) => {
if (data.code === 200) {
const newStatus = record.isDisabled === 1 ? 0 : 1;
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1, {
...record,
isDisabled: newStatus,
});
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const MoreBtn: React.FC<{
record: any;
index: number;
}> = ({ record, index }) => (
<Dropdown
arrow={{ pointAtCenter: true }}
placement="bottomRight"
trigger={['click']}
overlay={
<Menu
onClick={({ key, domEvent }) => {
domEvent.stopPropagation();
action(key, record, index);
}}
>
<Menu.Item key="edit" icon={<EditOutlined />}>
</Menu.Item>
<Menu.Item
key="enableOrDisable"
icon={
record.isDisabled === 1 ? (
<CheckCircleOutlined />
) : (
<StopOutlined />
)
}
>
{record.isDisabled === 1 ? '启用' : '禁用'}
</Menu.Item>
<Menu.Item key="delete" icon={<DeleteOutlined />}>
</Menu.Item>
</Menu>
}
>
<a onClick={(e) => e.stopPropagation()}>
<EllipsisOutlined />
</a>
</Dropdown>
);
const action = (key: string | number, record: any, index: number) => {
switch (key) {
case 'edit':
editSubscription(record, index);
break;
case 'enableOrDisable':
enabledOrDisabledSubscription(record, index);
break;
case 'delete':
delSubscription(record, index);
break;
default:
break;
}
};
const handleCancel = (subscription?: any) => {
setIsModalVisible(false);
if (subscription) {
handleSubscriptions(subscription);
}
};
const onSearch = (value: string) => {
setSearchText(value.trim());
};
const handleSubscriptions = (subscription: any) => {
const index = value.findIndex((x) => x.id === subscription.id);
const result = [...value];
if (index === -1) {
result.unshift(subscription);
} else {
result.splice(index, 1, {
...subscription,
});
}
setValue(result);
};
const onPageChange = (page: number, pageSize: number | undefined) => {
setCurrentPage(page);
setPageSize(pageSize as number);
localStorage.setItem('pageSize', pageSize + '');
};
const getRowClassName = (record: any, index: number) => {
return record.isPinned
? 'pinned-subscription subscription'
: 'subscription';
};
useEffect(() => {
getSubscriptions();
}, [searchText]);
useEffect(() => {
setPageSize(parseInt(localStorage.getItem('pageSize') || '20'));
setTimeout(() => {
setTableScrollHeight(getTableScroll());
});
}, []);
return (
<PageContainer
className="ql-container-wrapper subscriptiontab-wrapper"
title="订阅管理"
extra={[
<Search
placeholder="请输入名称或者关键词"
style={{ width: 'auto' }}
enterButton
allowClear
loading={loading}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onSearch={onSearch}
/>,
<Button key="2" type="primary" onClick={() => addSubscription()}>
</Button>,
]}
header={{
style: headerStyle,
}}
>
<Table
columns={columns}
pagination={{
current: currentPage,
onChange: onPageChange,
pageSize: pageSize,
showSizeChanger: true,
simple: isPhone,
defaultPageSize: 20,
showTotal: (total: number, range: number[]) =>
`${range[0]}-${range[1]} 条/总共 ${total}`,
pageSizeOptions: [20, 100, 500, 1000] as any,
}}
dataSource={value}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: tableScrollHeight }}
loading={loading}
rowClassName={getRowClassName}
/>
<SubscriptionModal
visible={isModalVisible}
handleCancel={handleCancel}
subscription={editedSubscription}
/>
</PageContainer>
);
};
export default Subscription;

View File

@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form, Button } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import cron_parser from 'cron-parser';
import EditableTagGroup from '@/components/tag';
const SubscriptionModal = ({
subscription,
handleCancel,
visible,
}: {
subscription?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleOk = async (values: any) => {
setLoading(true);
const method = subscription ? 'put' : 'post';
const payload = { ...values };
if (subscription) {
payload.id = subscription.id;
}
try {
const { code, data } = await request[method](
`${config.apiPrefix}subscriptions`,
{
data: payload,
},
);
if (code === 200) {
message.success(
subscription ? '更新Subscription成功' : '新建Subscription成功',
);
} else {
message.error(data);
}
setLoading(false);
handleCancel(data);
} catch (error: any) {
setLoading(false);
}
};
useEffect(() => {
form.resetFields();
}, [subscription, visible]);
return (
<Modal
title={subscription ? '编辑订阅' : '新建订阅'}
visible={visible}
forceRender
onOk={() => {
form
.validateFields()
.then((values) => {
handleOk(values);
})
.catch((info) => {
console.log('Validate Failed:', info);
});
}}
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
name="form_in_modal"
initialValues={subscription}
>
<Form.Item name="name" label="名称">
<Input placeholder="请输入订阅名称" />
</Form.Item>
<Form.Item
name="command"
label="命令"
rules={[{ required: true, whitespace: true }]}
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入要执行的命令"
/>
</Form.Item>
<Form.Item
name="schedule"
label="定时规则"
rules={[
{ required: true },
{
validator: (rule, value) => {
if (!value || cron_parser.parseExpression(value).hasNext()) {
return Promise.resolve();
} else {
return Promise.reject('Subscription表达式格式有误');
}
},
},
]}
>
<Input placeholder="秒(可选) 分 时 天 月 周" />
</Form.Item>
<Form.Item name="labels" label="标签">
<EditableTagGroup />
</Form.Item>
</Form>
</Modal>
);
};
export default SubscriptionModal;

View File

@ -43,6 +43,10 @@ export default {
name: '环境变量', name: '环境变量',
value: 'envs', value: 'envs',
}, },
{
name: '订阅管理',
value: 'subscriptions',
},
{ {
name: '配置文件', name: '配置文件',
value: 'configs', value: 'configs',
@ -67,6 +71,7 @@ export default {
scopesMap: { scopesMap: {
crons: '定时任务', crons: '定时任务',
envs: '环境变量', envs: '环境变量',
subscriptions: '订阅管理',
configs: '配置文件', configs: '配置文件',
scripts: '脚本管理', scripts: '脚本管理',
logs: '任务日志', logs: '任务日志',
@ -214,6 +219,7 @@ export default {
'/initialization': '初始化', '/initialization': '初始化',
'/cron': '定时任务', '/cron': '定时任务',
'/env': '环境变量', '/env': '环境变量',
'/subscription': '订阅管理',
'/config': '配置文件', '/config': '配置文件',
'/script': '脚本管理', '/script': '脚本管理',
'/diff': '对比工具', '/diff': '对比工具',