From f8f63890e5d3ef72a52d26585b595d02269afa78 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 27 Aug 2022 14:50:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/cron.ts | 91 +++++++++++ back/data/cronView.ts | 47 ++++++ back/loaders/db.ts | 3 +- back/services/cronView.ts | 82 ++++++++++ src/pages/crontab/index.less | 19 +++ src/pages/crontab/index.tsx | 76 +++++++++ src/pages/crontab/viewCreateModal.tsx | 82 ++++++++++ src/pages/crontab/viewManageModal.tsx | 223 ++++++++++++++++++++++++++ 8 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 back/data/cronView.ts create mode 100644 back/services/cronView.ts create mode 100644 src/pages/crontab/viewCreateModal.tsx create mode 100644 src/pages/crontab/viewManageModal.tsx diff --git a/back/api/cron.ts b/back/api/cron.ts index dc938d2c..0eded1f5 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import CronService from '../services/cron'; +import CronViewService from '../services/cronView'; import { celebrate, Joi } from 'celebrate'; import cron_parser from 'cron-parser'; const route = Router(); @@ -9,6 +10,96 @@ const route = Router(); export default (app: Router) => { app.use('/crons', route); + route.get( + '/views', + async (req: Request, res: Response, next: NextFunction) => { + try { + const cronViewService = Container.get(CronViewService); + const data = await cronViewService.list(); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.post( + '/views', + celebrate({ + body: Joi.object({ + name: Joi.string().required(), + sorts: Joi.string().required(), + filters: Joi.string().optional(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const cronViewService = Container.get(CronViewService); + const data = await cronViewService.create(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.put( + '/views', + celebrate({ + body: Joi.object({ + name: Joi.string().required(), + id: Joi.number().required(), + sorts: Joi.string().optional(), + filters: Joi.string().optional(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const cronViewService = Container.get(CronViewService); + const data = await cronViewService.update(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.delete( + '/views', + celebrate({ + body: Joi.array().items(Joi.number().required()), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const cronViewService = Container.get(CronViewService); + const data = await cronViewService.remove(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + + route.put( + '/views/move', + celebrate({ + body: Joi.object({ + fromIndex: Joi.number().required(), + toIndex: Joi.number().required(), + id: Joi.number().required(), + }), + }), + async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { + try { + const cronViewService = Container.get(CronViewService); + const data = await cronViewService.move(req.body); + return res.send({ code: 200, data }); + } catch (e) { + return next(e); + } + }, + ); + route.get( '/', celebrate({ diff --git a/back/data/cronView.ts b/back/data/cronView.ts new file mode 100644 index 00000000..c1da1bb5 --- /dev/null +++ b/back/data/cronView.ts @@ -0,0 +1,47 @@ +import { sequelize } from '.'; +import { DataTypes, Model } from 'sequelize'; + +interface SortType { + type: 'ASD' | 'DESC'; + value: string; +} + +interface FilterType { + type: 'or' | 'and'; + value: string; +} + +export class CrontabView { + name?: string; + id?: number; + position?: number; + isDisabled?: 1 | 0; + filters?: FilterType[]; + sorts?: SortType[]; + + constructor(options: CrontabView) { + this.name = options.name; + this.id = options.id; + this.position = options.position; + this.isDisabled = options.isDisabled; + this.filters = options.filters; + this.sorts = options.sorts; + } +} + +interface CronViewInstance + extends Model, + CrontabView {} +export const CrontabViewModel = sequelize.define( + 'CrontabView', + { + name: { + unique: 'name', + type: DataTypes.STRING, + }, + position: DataTypes.NUMBER, + isDisabled: DataTypes.NUMBER, + filters: DataTypes.JSON, + sorts: DataTypes.JSON, + }, +); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index e100abcf..6a4308ee 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -6,9 +6,9 @@ import { CrontabModel } from '../data/cron'; import { DependenceModel } from '../data/dependence'; import { AppModel } from '../data/open'; import { AuthModel } from '../data/auth'; -import { sequelize } from '../data'; import { fileExist } from '../config/util'; import { SubscriptionModel } from '../data/subscription'; +import { CrontabViewModel } from '../data/cronView'; import config from '../config'; export default async () => { @@ -19,6 +19,7 @@ export default async () => { await AuthModel.sync(); await EnvModel.sync(); await SubscriptionModel.sync(); + await CrontabViewModel.sync(); // try { // const queryInterface = sequelize.getQueryInterface(); diff --git a/back/services/cronView.ts b/back/services/cronView.ts new file mode 100644 index 00000000..db0cd47b --- /dev/null +++ b/back/services/cronView.ts @@ -0,0 +1,82 @@ +import { Service, Inject } from 'typedi'; +import winston from 'winston'; +import { CrontabView, CrontabViewModel } from '../data/cronView'; + +@Service() +export default class CronViewService { + constructor(@Inject('logger') private logger: winston.Logger) {} + + public async create(payload: CrontabView): Promise { + const tab = new CrontabView(payload); + const doc = await this.insert(tab); + return doc; + } + + public async insert(payload: CrontabView): Promise { + return await CrontabViewModel.create(payload, { returning: true }); + } + + public async update(payload: CrontabView): Promise { + const newDoc = await this.updateDb(payload); + return newDoc; + } + + public async updateDb(payload: CrontabView): Promise { + await CrontabViewModel.update(payload, { where: { id: payload.id } }); + return await this.getDb({ id: payload.id }); + } + + public async remove(ids: number[]) { + await CrontabViewModel.destroy({ where: { id: ids } }); + } + + public async list(): Promise { + try { + const result = await CrontabViewModel.findAll({}); + return result; + } catch (error) { + throw error; + } + } + + public async getDb(query: any): Promise { + const doc: any = await CrontabViewModel.findOne({ where: { ...query } }); + return doc && (doc.get({ plain: true }) as CrontabView); + } + + public async disabled(ids: number[]) { + await CrontabViewModel.update({ isDisabled: 1 }, { where: { id: ids } }); + } + + public async enabled(ids: number[]) { + await CrontabViewModel.update({ isDisabled: 0 }, { where: { id: ids } }); + } + + public async move({ + id, + fromIndex, + toIndex, + }: { + fromIndex: number; + toIndex: number; + id: number; + }): Promise { + let targetPosition: number; + const isUpward = fromIndex > toIndex; + const views = await this.list(); + if (toIndex === 0 || toIndex === views.length - 1) { + targetPosition = isUpward + ? views[0].position * 2 + : views[toIndex].position / 2; + } else { + targetPosition = isUpward + ? (views[toIndex].position + views[toIndex - 1].position) / 2 + : (views[toIndex].position + views[toIndex + 1].position) / 2; + } + const newDoc = await this.update({ + id, + position: targetPosition, + }); + return newDoc; + } +} diff --git a/src/pages/crontab/index.less b/src/pages/crontab/index.less index 71d7263d..289113d6 100644 --- a/src/pages/crontab/index.less +++ b/src/pages/crontab/index.less @@ -106,4 +106,23 @@ .ant-tabs-nav-wrap { flex: unset !important; } + + .view-more { + margin-left: 20px; + padding: 8px 0; + cursor: pointer; + + .ant-tabs-ink-bar { + width: 0; + } + + &:hover, + &:focus-visible { + color: #1890ff; + + .ant-tabs-ink-bar { + width: 50px; + } + } + } } diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 986426dc..66fe1a6b 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -28,6 +28,10 @@ import { PauseCircleOutlined, FieldTimeOutlined, PushpinOutlined, + DownOutlined, + SettingOutlined, + PlusOutlined, + UnorderedListOutlined, } from '@ant-design/icons'; import config from '@/utils/config'; import { PageContainer } from '@ant-design/pro-layout'; @@ -40,6 +44,8 @@ import { diffTime } from '@/utils/date'; import { getTableScroll } from '@/utils/index'; import { history } from 'umi'; import './index.less'; +import ViewCreateModal from './viewCreateModal'; +import ViewManageModal from './viewManageModal'; const { Text, Paragraph } = Typography; const { Search } = Input; @@ -355,6 +361,10 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => { const [detailCron, setDetailCron] = useState(); const [searchValue, setSearchValue] = useState(''); const [total, setTotal] = useState(); + const [isCreateViewModalVisible, setIsCreateViewModalVisible] = + useState(false); + const [isViewManageModalVisible, setIsViewManageModalVisible] = + useState(false); const goToScriptManager = (record: any) => { const cmd = record.command.split(' ') as string[]; @@ -909,6 +919,49 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => { ); + const viewAction = (key: string) => { + switch (key) { + case 'new': + setIsCreateViewModalVisible(true); + break; + case 'manage': + setIsViewManageModalVisible(true); + break; + + default: + break; + } + }; + + const menu = ( + { + domEvent.stopPropagation(); + viewAction(key); + }} + items={[ + { + label: 'Clicking me will not close the menu.', + key: '1', + icon: , + }, + { + type: 'divider', + }, + { + label: '新建视图', + key: 'new', + icon: , + }, + { + label: '视图管理', + key: 'manage', + icon: , + }, + ]} + /> + ); + return ( { size="small" tabPosition="top" className="crontab-view" + tabBarExtraContent={ + +
+ + 更多 + + +
+
+
+ } > {panelContent} @@ -974,6 +1038,18 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => { theme={theme} isPhone={isPhone} /> + { + setIsCreateViewModalVisible(false); + }} + /> + { + setIsViewManageModalVisible(false); + }} + />
); }; diff --git a/src/pages/crontab/viewCreateModal.tsx b/src/pages/crontab/viewCreateModal.tsx new file mode 100644 index 00000000..c7a5d4c8 --- /dev/null +++ b/src/pages/crontab/viewCreateModal.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { Modal, message, Input, Form, Statistic, Button } from 'antd'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; + +const ViewCreateModal = ({ + view, + handleCancel, + visible, +}: { + view?: any; + visible: boolean; + handleCancel: (param?: string) => void; +}) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + const handleOk = async (values: any) => { + setLoading(true); + const { value, name } = values; + const method = view ? 'put' : 'post'; + let payload; + if (!view) { + payload = [{ value, name }]; + } else { + payload = { ...values, id: view.id }; + } + try { + const { code, data } = await request[method]( + `${config.apiPrefix}crons/views`, + { + data: payload, + }, + ); + if (code !== 200) { + message.error(data); + } + setLoading(false); + handleCancel(data); + } catch (error: any) { + setLoading(false); + } + }; + + useEffect(() => { + form.resetFields(); + }, [view, visible]); + + return ( + { + form + .validateFields() + .then((values) => { + handleOk(values); + }) + .catch((info) => { + console.log('Validate Failed:', info); + }); + }} + onCancel={() => handleCancel()} + confirmLoading={loading} + > +
+ + + +
+
+ ); +}; + +export default ViewCreateModal; diff --git a/src/pages/crontab/viewManageModal.tsx b/src/pages/crontab/viewManageModal.tsx new file mode 100644 index 00000000..457b31d7 --- /dev/null +++ b/src/pages/crontab/viewManageModal.tsx @@ -0,0 +1,223 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Modal, message, Space, Table, Tag, Typography } from 'antd'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; +import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { PageLoading } from '@ant-design/pro-layout'; +import Paragraph from 'antd/lib/skeleton/Paragraph'; +import { DndProvider, useDrag, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +const { Text } = Typography; + +const type = 'DragableBodyRow'; + +const DragableBodyRow = ({ + index, + moveRow, + className, + style, + ...restProps +}: any) => { + const ref = useRef(); + const [{ isOver, dropClassName }, drop] = useDrop({ + accept: type, + collect: (monitor) => { + const { index: dragIndex } = (monitor.getItem() as any) || {}; + if (dragIndex === index) { + return {}; + } + return { + isOver: monitor.isOver(), + dropClassName: + dragIndex < index ? ' drop-over-downward' : ' drop-over-upward', + }; + }, + drop: (item: any) => { + moveRow(item.index, index); + }, + }); + const [, drag] = useDrag({ + type, + item: { index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drop(drag(ref)); + + return ( + + ); +}; + +const ViewManageModal = ({ + handleCancel, + visible, +}: { + visible: boolean; + handleCancel: () => void; +}) => { + const columns: any = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + align: 'center' as const, + }, + { + title: '显示', + key: 'status', + dataIndex: 'status', + align: 'center' as const, + width: 70, + render: (text: string, record: any, index: number) => { + return ; + }, + }, + { + title: '操作', + key: 'action', + width: 120, + align: 'center' as const, + render: (text: string, record: any, index: number) => { + return ( + + editView(record, index)}> + + + deleteView(record, index)}> + + + + ); + }, + }, + ]; + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + + const editView = (record: any, index: number) => { + // setEditedEnv(record); + // setIsModalVisible(true); + }; + + const deleteView = (record: any, index: number) => { + Modal.confirm({ + title: '确认删除', + content: ( + <> + 确认删除视图{' '} + + {record.name} + {' '} + 吗 + + ), + onOk() { + request + .delete(`${config.apiPrefix}crons/views`, { data: [record.id] }) + .then((data: any) => { + if (data.code === 200) { + message.success('删除成功'); + const result = [...list]; + result.splice(index, 1); + setList(result); + } else { + message.error(data); + } + }); + }, + onCancel() { + console.log('Cancel'); + }, + }); + }; + + const getCronViews = () => { + setLoading(true); + request + .get(`${config.apiPrefix}crons/views`) + .then((data: any) => { + console.log(data); + }) + .finally(() => { + setLoading(false); + }); + }; + + const components = { + body: { + row: DragableBodyRow, + }, + }; + + const moveRow = useCallback( + (dragIndex, hoverIndex) => { + if (dragIndex === hoverIndex) { + return; + } + const dragRow = list[dragIndex]; + request + .put(`${config.apiPrefix}envs/${dragRow.id}/move`, { + data: { fromIndex: dragIndex, toIndex: hoverIndex }, + }) + .then((data: any) => { + if (data.code === 200) { + const newData = [...list]; + newData.splice(dragIndex, 1); + newData.splice(hoverIndex, 0, { ...dragRow, ...data.data }); + setList(newData); + } else { + message.error(data); + } + }); + }, + [list], + ); + + useEffect(() => { + getCronViews(); + }, []); + + return ( + handleCancel()} + className="view-manage-modal" + forceRender + footer={false} + > + {loading ? ( + + ) : ( + + { + return { + index, + moveRow, + } as any; + }} + /> + + )} + + ); +}; + +export default ViewManageModal;