feat(flowEditor): 增加周期性节点支持

- 新增 CronPicker 组件用于编辑周期表达式
- 在 CycleEditor 中集成 CronPicker,支持周期参数配置- 优化 NodeContent 组件,支持显示周期性节点的执行周期
- 新增 isJSON 函数用于判断是否为 JSON 字符串
master
钟良源 5 months ago
parent 682412784b
commit 943ac9e648

@ -20,6 +20,7 @@ interface CronPickerProps {
onChange?: (value: string) => void; onChange?: (value: string) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
onUpdateData: (data) => void;
} }
const PRESETS = [ const PRESETS = [
@ -42,7 +43,7 @@ const dayMap = {
'SUN': '周日' 'SUN': '周日'
}; };
const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, className }) => { const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, className, onUpdateData }) => {
const [mode, setMode] = useState<'visual' | 'expression'>('visual'); const [mode, setMode] = useState<'visual' | 'expression'>('visual');
const [cron, setCron] = useState<string>(value || '0 0 0 * * ?'); const [cron, setCron] = useState<string>(value || '0 0 0 * * ?');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
@ -56,6 +57,8 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
const [specificDay, setSpecificDay] = useState<number>(1); const [specificDay, setSpecificDay] = useState<number>(1);
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(['MON']); const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(['MON']);
const [selectedMonths, setSelectedMonths] = useState<string[]>(['1']); const [selectedMonths, setSelectedMonths] = useState<string[]>(['1']);
const [monthlyType, setMonthlyType] = useState<'every' | 'specific'>('every');
const [specificMonths, setSpecificMonths] = useState<string[]>(['1']);
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
@ -108,7 +111,11 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
dow = selectedWeekdays.length > 0 ? selectedWeekdays.join(',') : '*'; dow = selectedWeekdays.length > 0 ? selectedWeekdays.join(',') : '*';
} }
const mon = selectedMonths.length < 12 ? selectedMonths.join(',') : '*'; const mon = monthlyType === 'every'
? '*'
: specificMonths.length > 0
? specificMonths.join(',')
: '*';
const newCron = `${sec} ${min} ${hour} ${dom} ${mon} ${dow}`; const newCron = `${sec} ${min} ${hour} ${dom} ${mon} ${dow}`;
return newCron; return newCron;
// validateAndSet(newCron); // validateAndSet(newCron);
@ -119,7 +126,14 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
return generateCron(); return generateCron();
} }
return cron; return cron;
}, [mode, minuteType, minutesInterval, hourType, hourlyAt, dailyType, specificDay, selectedWeekdays, selectedMonths, cron]); }, [mode, minuteType, minutesInterval, hourType, hourlyAt, dailyType, specificDay, selectedWeekdays, selectedMonths, monthlyType, specificMonths, cron]);
useEffect(() => {
onUpdateData({
customDef: { intervalSeconds: currentCron },
type: 'CYCLE'
});
}, [currentCron]);
// 解析Cron表达式并更新图形化配置的状态 // 解析Cron表达式并更新图形化配置的状态
const parseCronExpression = (cronExpression: string) => { const parseCronExpression = (cronExpression: string) => {
@ -189,11 +203,18 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
} }
// 解析月份部分 // 解析月份部分
if (month !== '*' && month.includes(',')) { if (month === '*') {
setMonthlyType('every');
}
else if (month.includes(',')) {
setMonthlyType('specific');
setSelectedMonths(month.split(',')); setSelectedMonths(month.split(','));
setSpecificMonths(month.split(','));
} }
else if (month !== '*') { else if (month !== '*') {
setMonthlyType('specific');
setSelectedMonths([month]); setSelectedMonths([month]);
setSpecificMonths([month]);
} }
} }
}; };
@ -225,7 +246,6 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
const getNextTime = (): string => { const getNextTime = (): string => {
try { try {
console.log('currentCron:', currentCron);
const interval = CronExpressionParser.parse(currentCron); const interval = CronExpressionParser.parse(currentCron);
const next = interval.next(); const next = interval.next();
return next?.toDate ? next.toDate().toLocaleString() : '无法计算'; return next?.toDate ? next.toDate().toLocaleString() : '无法计算';
@ -379,6 +399,43 @@ const CronPicker: React.FC<CronPickerProps> = ({ value, onChange, style, classNa
</Form.Item> </Form.Item>
)} )}
<Form.Item label="月份" className={styles.formItem}>
<Radio.Group
value={monthlyType}
onChange={(value) => setMonthlyType(value as 'every' | 'specific')}
style={{ display: 'flex' }}
>
<Space size="large">
<Radio value="every"></Radio>
<Radio value="specific"></Radio>
</Space>
</Radio.Group>
</Form.Item>
{monthlyType === 'specific' && (
<Form.Item label="指定月份" className={styles.formItem}>
<div className={styles.inlineGroup}>
{Array.from({ length: 12 }, (_, i) => i + 1).map(month => (
<Button
key={month}
type={specificMonths.includes(String(month)) ? 'primary' : 'secondary'}
size="small"
className={styles.weekButton}
onClick={() => {
setSpecificMonths(prev =>
prev.includes(String(month))
? prev.filter(m => m !== String(month))
: [...prev, String(month)]
);
}}
>
{month}
</Button>
))}
</div>
</Form.Item>
)}
<Space wrap size="small" className={styles.presetsContainer}> <Space wrap size="small" className={styles.presetsContainer}>
{PRESETS.map(p => ( {PRESETS.map(p => (
<Button key={p.value} type="text" size="small" onClick={() => updateCron(p.value)}> <Button key={p.value} type="text" size="small" onClick={() => updateCron(p.value)}>

@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import styles from '@/pages/flowEditor/node/style/base.module.less'; import styles from '@/pages/flowEditor/node/style/base.module.less';
import { Handle, Position, useStore } from '@xyflow/react'; import { Handle, Position, useStore } from '@xyflow/react';
import { isJSON } from '@/utils/common';
import cronstrue from 'cronstrue/i18n';
interface NodeContentData { interface NodeContentData {
parameters?: { parameters?: {
@ -176,12 +178,19 @@ const renderRegularNodeHandles = (dataIns: any[], dataOuts: any[], apiIns: any[]
); );
}; };
// 通过duration计算时分秒 const formatFooter = (data: any) => {
const formatDuration = (duration: number) => { // TODO 这里后续需要优化
const hours = Math.floor(duration / 3600); if (isJSON(data)) {
const minutes = Math.floor((duration % 3600) / 60); const { duration } = JSON.parse(data);
const seconds = Math.floor(duration % 60); const hours = Math.floor(duration / 3600);
return `${hours}小时${minutes}分钟${seconds}`; const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return `${hours}小时${minutes}分钟${seconds}`;
}
else {
const keyMap = ['intervalSeconds'];
return cronstrue.toString(data.intervalSeconds, { locale: 'zh_CN' });
}
}; };
const NodeContent = ({ data }: { data: NodeContentData }) => { const NodeContent = ({ data }: { data: NodeContentData }) => {
@ -190,7 +199,7 @@ const NodeContent = ({ data }: { data: NodeContentData }) => {
const dataIns = data.parameters?.dataIns || []; const dataIns = data.parameters?.dataIns || [];
const dataOuts = data.parameters?.dataOuts || []; const dataOuts = data.parameters?.dataOuts || [];
const showFooter = data?.component?.customDef || false; const showFooter = data?.component?.customDef || false;
const footerData = (showFooter && JSON.parse(data.component?.customDef)) || {}; const footerData = (showFooter && data.component?.customDef) || {};
// 判断节点类型 // 判断节点类型
const isStartNode = data.type === 'start'; const isStartNode = data.type === 'start';
@ -225,7 +234,7 @@ const NodeContent = ({ data }: { data: NodeContentData }) => {
{/*footer栏*/} {/*footer栏*/}
{showFooter && ( {showFooter && (
<div className={styles['node-footer']}> <div className={styles['node-footer']}>
{formatDuration(footerData.duration)} {formatFooter(footerData)}
</div> </div>
)} )}

@ -1,12 +1,24 @@
import React from 'react'; import React, { useState } from 'react';
import { NodeEditorProps } from '@/pages/flowEditor/nodeEditors'; import { NodeEditorProps } from '@/pages/flowEditor/nodeEditors';
import { Typography } from '@arco-design/web-react'; import { Typography } from '@arco-design/web-react';
import { IconUnorderedList } from '@arco-design/web-react/icon'; import { IconUnorderedList } from '@arco-design/web-react/icon';
import ParamsTable from './ParamsTable'; import ParamsTable from './ParamsTable';
import CronPicker from '@/components/CronPicker';
const CycleEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) => { const CycleEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) => {
const [cron, setCron] = useState('0 0 9 * * ?');
return ( return (
<> <>
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title>
<CronPicker
value={cron}
onChange={setCron}
onUpdateData={(data) => {
updateNodeData('component', {
...data
});
}} />
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title> <Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title>
<ParamsTable <ParamsTable
initialData={nodeData.parameters.dataIns || []} initialData={nodeData.parameters.dataIns || []}
@ -17,6 +29,16 @@ const CycleEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) =>
}); });
}} }}
/> />
<Typography.Title heading={5}><IconUnorderedList style={{ marginRight: 5 }} /></Typography.Title>
<ParamsTable
initialData={nodeData.parameters.dataOuts || []}
onUpdateData={(data) => {
updateNodeData('parameters', {
...nodeData.parameters,
dataOuts: data
});
}}
/>
</> </>
); );
}; };

@ -13,7 +13,6 @@ interface DurationType {
} }
const WaitEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) => { const WaitEditor: React.FC<NodeEditorProps> = ({ nodeData, updateNodeData }) => {
console.log('nodeData:', nodeData);
const hourOptions = Array.from({ length: 23 }, (_, i) => i); const hourOptions = Array.from({ length: 23 }, (_, i) => i);
const minuteOptions = Array.from({ length: 59 }, (_, i) => i); const minuteOptions = Array.from({ length: 59 }, (_, i) => i);
const secondOptions = Array.from({ length: 59 }, (_, i) => i); const secondOptions = Array.from({ length: 59 }, (_, i) => i);

@ -106,4 +106,15 @@ export function formatDataType(value: string): string {
]); ]);
return dataTypeAbbr.get(value) || value; return dataTypeAbbr.get(value) || value;
}
// 判断是否为JSON字符串
export function isJSON(str: any): boolean {
if (typeof str !== 'string') return false;
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
} }
Loading…
Cancel
Save