import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import { SharedContext } from '@/layouts'; import { getCommandScript, getCrontabsNextDate } from '@/utils'; import config from '@/utils/config'; import { diffTime } from '@/utils/date'; import { request } from '@/utils/http'; import { CheckCircleOutlined, CheckOutlined, ClockCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EllipsisOutlined, FieldTimeOutlined, Loading3QuartersOutlined, PlusOutlined, PushpinOutlined, SettingOutlined, StopOutlined, UnorderedListOutlined, } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import { history, useOutletContext } from '@umijs/max'; import { Button, Dropdown, Input, MenuProps, message, Modal, Space, Table, TablePaginationConfig, Tabs, Tag, Typography, } from 'antd'; import { ColumnProps } from 'antd/lib/table'; import { FilterValue, SorterResult } from 'antd/lib/table/interface'; import dayjs from 'dayjs'; import { noop, omit } from 'lodash'; import React, { useEffect, useRef, useState } from 'react'; import intl from 'react-intl-universal'; import { useVT } from 'virtualizedtableforantd4'; import { getScheduleType } from './const'; import CronDetailModal from './detail'; import './index.less'; import CronLogModal from './logModal'; import CronModal, { CronLabelModal } from './modal'; import { CrontabStatus, ICrontab, OperationName, OperationPath, ScheduleType, } from './type'; import ViewCreateModal from './viewCreateModal'; import ViewManageModal from './viewManageModal'; const { Text, Paragraph, Link } = Typography; const { Search } = Input; const SHOW_TAB_COUNT = 10; const Crontab = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [allSubscriptions, setAllSubscriptions] = useState([]); const columns: ColumnProps[] = [ { title: intl.get('名称'), dataIndex: 'name', key: 'name', fixed: 'left', width: 120, render: (text: string, record: any) => ( { setDetailCron(record); setIsDetailModalVisible(true); }} > {record.name || '-'} ), sorter: { compare: (a, b) => a?.name?.localeCompare(b?.name), }, }, { title: intl.get('命令/脚本'), dataIndex: 'command', key: 'command', width: 240, render: (text, record) => { return ( { goToScriptManager(record); }} > {text} ); }, sorter: { compare: (a: any, b: any) => a.command.localeCompare(b.command), }, }, { title: intl.get('状态'), key: 'status', dataIndex: 'status', width: 100, filters: [ { text: intl.get('运行中'), value: CrontabStatus.running, }, { text: intl.get('空闲中'), value: CrontabStatus.idle, }, { text: intl.get('已禁用'), value: CrontabStatus.disabled, }, { text: intl.get('队列中'), value: CrontabStatus.queued, }, ], render: (text, record) => ( <> {(!record.isDisabled || record.status !== CrontabStatus.idle) && ( <> {record.status === CrontabStatus.idle && ( } color="default"> {intl.get('空闲中')} )} {record.status === CrontabStatus.running && ( } color="processing" > {intl.get('运行中')} )} {record.status === CrontabStatus.queued && ( } color="default"> {intl.get('队列中')} )} )} {record.isDisabled === 1 && record.status === CrontabStatus.idle && ( } color="error"> {intl.get('已禁用')} )} ), }, { title: intl.get('定时规则'), dataIndex: 'schedule', key: 'schedule', width: 150, sorter: { compare: (a, b) => a.schedule.localeCompare(b.schedule), }, render: (text, record) => { return (
{text}
{record.extra_schedules?.map((x) => (
{x.schedule}
))} ), }, rows: 2, }} > {text}
); }, }, { title: intl.get('最后运行时长'), width: 167, dataIndex: 'last_running_time', key: 'last_running_time', sorter: { compare: (a: any, b: any) => { return a.last_running_time - b.last_running_time; }, }, render: (text, record) => { return record.last_running_time ? diffTime(record.last_running_time) : '-'; }, }, { title: intl.get('最后运行时间'), dataIndex: 'last_execution_time', key: 'last_execution_time', width: 141, sorter: { compare: (a, b) => { return (a.last_execution_time || 0) - (b.last_execution_time || 0); }, }, render: (text, record) => { return ( {record.last_execution_time ? dayjs(record.last_execution_time * 1000).format( 'YYYY-MM-DD HH:mm:ss', ) : '-'} ); }, }, { title: intl.get('下次运行时间'), width: 144, sorter: { compare: (a: any, b: any) => { return a.nextRunTime - b.nextRunTime; }, }, render: (text, record) => { return record.nextRunTime ? dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss') : '-'; }, }, { title: intl.get('关联订阅'), width: 185, render: (text, record: any) => record?.subscription?.name || '-', key: 'sub_id', dataIndex: 'sub_id', filters: allSubscriptions.map((sub) => ({ text: sub.name || sub.alias, value: sub.id, })), }, { title: intl.get('操作'), key: 'action', width: 140, fixed: isPhone ? undefined : 'right', render: (text, record, index) => { const isPc = !isPhone; return ( {record.status === CrontabStatus.idle && ( { e.stopPropagation(); runCron(record, index); }} > {intl.get('运行')} )} {record.status !== CrontabStatus.idle && ( { e.stopPropagation(); stopCron(record, index); }} > {intl.get('停止')} )} { e.stopPropagation(); setLogCron({ ...record, timestamp: Date.now() }); }} > {intl.get('日志')} ); }, }, ]; const [value, setValue] = useState([]); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [isLabelModalVisible, setIsLabelModalVisible] = useState(false); const [editedCron, setEditedCron] = useState(); const [searchText, setSearchText] = useState(''); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [logCron, setLogCron] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [pageConf, setPageConf] = useState<{ page: number; size: number; sorter: any; filters: any; }>({} as any); const [viewConf, setViewConf] = useState(); const [isDetailModalVisible, setIsDetailModalVisible] = useState(false); const [detailCron, setDetailCron] = useState(); const [searchValue, setSearchValue] = useState(''); const [total, setTotal] = useState(); const [isCreateViewModalVisible, setIsCreateViewModalVisible] = useState(false); const [isViewManageModalVisible, setIsViewManageModalVisible] = useState(false); const [cronViews, setCronViews] = useState([]); const [enabledCronViews, setEnabledCronViews] = useState([]); const [moreMenuActive, setMoreMenuActive] = useState(false); const tableRef = useRef(null); const tableScrollHeight = useTableScrollHeight(tableRef); const [activeKey, setActiveKey] = useState(''); const goToScriptManager = (record: any) => { const result = getCommandScript(record.command); if (Array.isArray(result)) { const [s, p] = result; history.push(`/script?p=${p}&s=${s}`); } else if (result) { location.href = result; } }; const getCrons = () => { setLoading(true); const { page, size, sorter, filters } = pageConf; let url = `${config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( filters, )}`; if (sorter && sorter.column && sorter.order) { url += `&sorter=${JSON.stringify({ field: sorter.column.key, type: sorter.order === 'ascend' ? 'ASC' : 'DESC', })}`; } if (viewConf) { url += `&queryString=${JSON.stringify({ filters: viewConf.filters, sorts: viewConf.sorts, filterRelation: viewConf.filterRelation || 'and', })}`; } request .get(url) .then(async ({ code, data: _data }) => { if (code === 200) { const { data, total } = _data; const subscriptions = await request.get( `${config.apiPrefix}subscriptions?ids=${JSON.stringify([ ...new Set(data.map((x) => x.sub_id).filter(Boolean)), ])}`, { onError: noop, }, ); const subscriptionMap = Object.fromEntries( subscriptions?.data?.map((x) => [x.id, x]), ); setValue( data.map((x) => { const scheduleType = getScheduleType(x.schedule); const nextRunTime = scheduleType === ScheduleType.Normal ? getCrontabsNextDate(x.schedule, x.extra_schedules) : null; return { ...x, nextRunTime, subscription: subscriptionMap?.[x.sub_id], }; }), ); setTotal(total); } }) .finally(() => setLoading(false)); }; const addCron = () => { setEditedCron(null as any); setIsModalVisible(true); }; const editCron = (record: any, index: number) => { setEditedCron(record); setIsModalVisible(true); }; const delCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .delete(`${config.apiPrefix}crons`, { data: [record.id] }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('删除成功')); const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1); setValue(result); } } }); }, }); }; const runCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认运行'), content: ( <> {intl.get('确认运行定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/run`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, status: CrontabStatus.running, }); setValue(result); } } }); }, }); }; const stopCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认停止'), content: ( <> {intl.get('确认停止定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/stop`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, pid: null, status: CrontabStatus.idle, }); setValue(result); } } }); }, }); }; const enabledOrDisabledCron = (record: any, index: number) => { Modal.confirm({ title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }`, content: ( <> {intl.get('确认')} {record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')} {intl.get('定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { const newStatus = record.isDisabled === 1 ? 0 : 1; const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, isDisabled: newStatus, }); setValue(result); } } }); }, }); }; const pinOrUnPinCron = (record: any, index: number) => { Modal.confirm({ title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }`, content: ( <> {intl.get('确认')} {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} {intl.get('定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { const newStatus = record.isPinned === 1 ? 0 : 1; const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, isPinned: newStatus, }); setValue(result); } } }); }, }); }; const getMenuItems = (record: any) => { return [ { label: intl.get('编辑'), key: 'edit', icon: }, { label: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), key: 'enableOrDisable', icon: record.isDisabled === 1 ? : , }, { label: intl.get('复制'), key: 'copy', icon: }, { label: intl.get('删除'), key: 'delete', icon: }, { label: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), key: 'pinOrUnPin', icon: record.isPinned === 1 ? : , }, ]; }; const MoreBtn: React.FC<{ record: any; index: number; }> = ({ record, index }) => ( { domEvent.stopPropagation(); action(key, record, index); }, }} > e.stopPropagation()}> ); const action = (key: string | number, record: any, index: number) => { switch (key) { case 'edit': editCron(record, index); break; case 'copy': editCron(omit(record, 'id'), index); break; case 'enableOrDisable': enabledOrDisabledCron(record, index); break; case 'delete': delCron(record, index); break; case 'pinOrUnPin': pinOrUnPinCron(record, index); break; default: break; } }; const handleCancel = () => { setIsModalVisible(false); getCrons(); }; const onSearch = (value: string) => { setSearchText(value.trim()); }; const getCronDetail = (cron: any) => { request .get(`${config.apiPrefix}crons/${cron.id}`) .then(({ code, data }) => { if (code === 200) { const index = value.findIndex((x) => x.id === cron.id); const result = [...value]; data.nextRunTime = getCrontabsNextDate( data.schedule, data.extra_schedules, ); if (index !== -1) { result.splice(index, 1, { ...cron, ...data, }); setValue(result); } } }) .finally(() => setLoading(false)); }; const onSelectChange = (selectedIds: any[]) => { setSelectedRowIds(selectedIds); }; const rowSelection = { selectedRowKeys: selectedRowIds, onChange: onSelectChange, }; const delCrons = () => { Modal.confirm({ title: intl.get('确认删除'), content: <>{intl.get('确认删除选中的定时任务吗')}, onOk() { request .delete(`${config.apiPrefix}crons`, { data: selectedRowIds }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('批量删除成功')); setSelectedRowIds([]); getCrons(); } }); }, }); }; const operateCrons = (operationStatus: number) => { Modal.confirm({ title: `确认${OperationName[operationStatus]}`, content: ( <> {intl.get('确认')} {OperationName[operationStatus]} {intl.get('选中的定时任务吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${OperationPath[operationStatus]}`, selectedRowIds, ) .then(({ code, data }) => { if (code === 200) { getCrons(); } }); }, }); }; const onPageChange = ( pagination: TablePaginationConfig, filters: Record, sorter: SorterResult | SorterResult[], ) => { const { current, pageSize } = pagination; setPageConf({ page: current as number, size: pageSize as number, sorter, filters, }); localStorage.setItem('pageSize', String(pageSize)); }; const getRowClassName = (record: any, index: number) => { return record.isPinned ? 'pinned-cron cron' : 'cron'; }; useEffect(() => { if (logCron) { localStorage.setItem('logCron', logCron.id); setIsLogModalVisible(true); } }, [logCron]); useEffect(() => { setPageConf({ ...pageConf, page: 1 }); }, [searchText]); useEffect(() => { if (pageConf.page && pageConf.size) { getCrons(); } if (viewConf && viewConf.id) { setActiveKey(viewConf.id); } }, [pageConf, viewConf]); useEffect(() => { if (viewConf && enabledCronViews && enabledCronViews.length > 0) { const view = enabledCronViews .slice(SHOW_TAB_COUNT) .find((x) => x.id === viewConf.id); setMoreMenuActive(!!view); } }, [viewConf, enabledCronViews]); const getAllSubscriptions = () => { request .get(`${config.apiPrefix}subscriptions`) .then(({ code, data }) => { if (code === 200) { setAllSubscriptions(data || []); } }) .catch(() => {}); }; useEffect(() => { getCronViews(); getAllSubscriptions(); }, []); const viewAction = (key: string) => { switch (key) { case 'new': setIsCreateViewModalVisible(true); break; case 'manage': setIsViewManageModalVisible(true); break; default: tabClick(key); break; } }; const menu: MenuProps = { onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); viewAction(key); }, items: [ ...[...enabledCronViews].slice(SHOW_TAB_COUNT).map((x) => ({ label: ( {x.name} {viewConf?.id === x.id && ( )} ), key: x.id, icon: , })), { type: 'divider' as 'group', }, { label: intl.get('创建视图'), key: 'new', icon: , }, { label: intl.get('视图管理'), key: 'manage', icon: , }, ], style: { maxHeight: 350, overflowY: 'auto', }, }; const getCronViews = () => { setLoading(true); request .get(`${config.apiPrefix}crons/views`) .then(({ code, data }) => { if (code === 200) { setCronViews(data); const firstEnableView = data .filter((x) => !x.isDisabled) .map((x) => ({ ...x, name: x.name === '全部任务' ? intl.get('全部任务') : x.name, })); setEnabledCronViews(firstEnableView); setPageConf({ page: 1, size: parseInt(localStorage.getItem('pageSize') || '20'), sorter: {}, filters: {}, }); setViewConf({ ...firstEnableView[0], }); } }) .finally(() => { setLoading(false); }); }; const tabClick = (key: string) => { const view = enabledCronViews.find((x) => x.id == key); setSelectedRowIds([]); setPageConf({ ...pageConf, page: 1 }); setViewConf(view ? view : null); }; const [vt] = useVT( () => ({ scroll: { y: tableScrollHeight } }), [tableScrollHeight], ); return ( setSearchValue(e.target.value)} onSearch={onSearch} />, , ]} header={{ style: headerStyle, }} >
{intl.get('更多')}
} onTabClick={tabClick} items={[ ...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({ key: x.id, label: x.name, })), ]} />
{selectedRowIds.length > 0 && (
{intl.get('已选择')} {selectedRowIds?.length} {intl.get('项')}
)} `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort( (a, b) => a - b, ), }} dataSource={value} rowKey="id" size="middle" scroll={{ x: 1200, y: tableScrollHeight }} loading={loading} rowSelection={rowSelection} rowClassName={getRowClassName} onChange={onPageChange} components={isPhone || pageConf.size < 50 ? undefined : vt} /> {isLogModalVisible && ( { getCronDetail(logCron); setIsLogModalVisible(false); }} cron={logCron} /> )} {isModalVisible && ( )} {isLabelModalVisible && ( { setIsLabelModalVisible(false); if (needUpdate) { getCrons(); } }} ids={selectedRowIds} /> )} {isDetailModalVisible && ( { setIsDetailModalVisible(false); }} cron={detailCron} theme={theme} isPhone={isPhone} /> )} {isCreateViewModalVisible && ( { setIsCreateViewModalVisible(false); getCronViews(); }} /> )} {isViewManageModalVisible && ( { setIsViewManageModalVisible(false); }} cronViewChange={(data) => { getCronViews(); }} /> )} ); }; export default Crontab;