📌 写给熟悉 Vue2.3 的你:
这篇文档讲的是「周期任务」功能的完整实现。碰到 React 的新概念、TypeScript 的新语法,我都会用 Vue 类比、用大白话给你讲明白。
先记住一个最重要的类比(跟之前的教程一致):
Vue 2 的概念 React 里对应的东西 一句话解释 data()里的数据useState()定义响应式变量,变了就重新渲染 computed计算属性函数里直接算 / useMemo根据已有数据算出新值 methods方法函数组件里定义的函数 处理点击等交互逻辑 mounted生命周期useEffect(() => {}, [])组件第一次渲染完后执行 watch侦听useEffect(() => {}, [依赖])某个值变了就执行 props父传子函数的参数 Props一模一样,父组件传进来的数据 $emit子传父父传一个回调函数当 props React 没有 $emit,靠"父传函数、子调用"来传值v-for循环渲染array.map(item => <div>...</div>)JS 原生的 map 方法 v-if条件渲染{条件 && <组件 />}或三元表达式JS 里的短路与 / 三元 :class动态类名className={...}用模板字符串拼类名 🌍 英文词汇小词典(先混个脸熟):
- CRON — 定时任务表达式(一串字符,表示"什么时候执行"的规则,比如
0 9 * * *表示每天早上9点)- recurring — 周期性的、重复发生的
- template — 模板(提前写好的格式,到时候往里填数据)
- variable — 变量(会变化的值,比如日期)
- execution — 执行
- audit — 审核
- syntax — 语法
- draft — 草稿(还没提交的临时内容)
- interval — 间隔
- valid — 有效的 / invalid — 无效的
前言
在任务协同系统中,有一类任务不需要人工创建——它们按照固定的时间周期自动生成,比如每日巡检、每周周报、每月对账等。这类「周期任务」的核心需求是:配置一条规则,系统按时自动生成任务。
本文从零实现一个完整的周期任务模块,涵盖:
- CRON 表达式可视化编辑器(图形化选择 + 手动输入 + 运行预览)
- 任务模板配置(标题/描述支持模板变量)
- 规则列表、创建、详情、编辑全生命周期
- 执行历史追踪(每次生成的任务都有记录)
- 审核与启停机制
本文基于真实生产项目脱敏整理,核心架构和设计思路保持原貌。
一、整体架构总览
🎓 小白课堂:周期任务是啥?为什么需要它?
想象你是一个运维工程师,每天早上9点都要做一次系统巡检。
每次手动创建一条"巡检任务"?太麻烦了。周期任务就是:你配置一条规则(“每天早上9点,生成一条标题叫’{{日期}} 系统巡检’的任务,负责人是张三”),系统就会自动按时给你生成任务。
就像你手机里的闹钟——设一次,每天自动响。
1.1 功能清单
| 功能 | 说明 |
|---|---|
| 规则列表 | 按名称/状态/创建时间筛选,支持启停/删除/审核操作 |
| 创建规则 | 四步表单:任务信息 → 联系人 → 处理人 → 定时规则 |
| 规则详情 | 展示规则信息 + 任务模板 + 执行历史表格 |
| 规则编辑 | 弹窗内编辑,提交后刷新 |
| CRON 编辑器 | 图形化五段式选择,手动输入同步,未来运行时间预览 |
| 模板变量 | 标题/描述支持 {{date.yyyy-MM-dd}} 等变量替换 |
| 执行历史 | 每次执行记录成功/失败,可跳转到生成的任务 |
| 审核流程 | 规则创建后需审核通过才能启用 |
1.2 数据模型关系
一条规则 (RecurringTaskRule)
│
├── 基本信息:名称/备注/状态/CRON 表达式
├── 任务模板 (TaskTemplate):标题/描述/分类/优先级/处理人
│ └── 标题/描述支持 {{date.xxx}} 模板变量
│
└── N 条执行历史 (ExecutionHistoryRecord)
├── 执行时间
├── 状态:成功/失败
├── 生成的任务 ID(成功时)
└── 结果说明
💡 大白话理解:
- 规则 = 闹钟本身(什么时候响、响什么内容)
- 任务模板 = 闹钟响了之后生成的任务长什么样
- 执行历史 = 闹钟每次响的记录(有没有响成功、生成了什么任务)
二、CRON 表达式编辑器(核心组件)
🎓 小白课堂:CRON 表达式是什么?
CRON(读作"克朗")是一种用来表示"什么时候执行"的字符串格式。
它由 5 个字段组成,用空格分开:分 时 日 月 周比如:
0 9 * * *= 每天早上 9:000 9 * * 1= 每周一早上 9:0030 8 1 * *= 每月 1 号早上 8:30其中
*表示"每"的意思。这个东西对新手来说很不友好(你一眼能看懂
*/5 9-17 * * 1-5是什么意思吗?),所以我们要做一个图形化编辑器,让用户点选就行,不用背语法。
CRON 编辑器是周期任务最核心的交互组件。让用户手写 0 9 * * * 这种表达式体验很差,所以我们做了三层:
- 图形化选择器:可视化选择分/时/日/月/周
- 手动输入框:高级用户直接写表达式,和图形化双向同步
- 运行时间预览:实时展示未来 10 次执行时间
2.1 技术选型
| 库 | 用途 |
|---|---|
react-js-cron | 图形化 CRON 选择器组件 |
cron-parser | 解析 CRON 表达式,计算未来运行时间 |
cronstrue | CRON 表达式转人性化中文描述 |
💡 为什么用三个库?
各司其职:一个管图形界面、一个管解析计算、一个管文字描述。
这就是前端开发的常态——站在巨人的肩膀上,不用什么都自己写。
2.2 组件封装
// src/components/cron-editor/index.tsx
// 👆 这是一个通用组件,放在 src/components 目录下
// 其他地方需要 CRON 编辑器时,直接引入这个组件就行
import { useState, useCallback } from 'react';
// 👆 从 react 包里引入两个 Hook:
// useState = 响应式状态(对应 Vue 的 data)
// useCallback = 缓存函数(对应 Vue methods 里的函数,只创建一次)
import { Input, Button, Modal, Space } from 'antd';
// 👆 从 antd(Ant Design)组件库引入一些基础 UI 组件
// 对应 Vue 项目里的 Element UI / Vant 等 UI 库
import Cron from 'react-js-cron';
// 👆 第三方的 CRON 图形化选择器组件
import cronstrue from 'cronstrue/i18n';
// 👆 第三方库:把 CRON 表达式转成人类能看懂的文字
// /i18n 表示国际化版本,支持中文
import 'react-js-cron/dist/styles.css';
// 👆 引入第三方组件的样式文件(对应 Vue 里 import 'xxx.css')
import { analyzeCronExpression } from './cron-utils';
// 👆 引入同级目录下的工具函数,用来解析和校验 CRON 表达式
// 👇 定义组件的 Props(接收的参数)
interface Props {
value: string;
// 当前 CRON 表达式的值(父组件传进来的)
onChange: (value: string) => void;
// 值变化时的回调函数(子组件改了值,通知父组件)
// 💡 这就是 React 的"受控组件"模式:值由父组件管理,子组件通过 onChange 上报
// 对应 Vue 里的 v-model(value + input 事件)
minIntervalMinutes?: number;
// 可选参数:最小执行间隔(分钟),防止用户设得太频繁
}
// 👇 组件函数定义
export function CronEditor({ value, onChange, minIntervalMinutes = 10 }: Props) {
// 👆 解构赋值:直接从 Props 对象里取出三个属性当变量用
// minIntervalMinutes = 10 是默认参数:不传时默认是 10 分钟
// ── 定义状态 ─────────────────────────────────
const [modalOpen, setModalOpen] = useState(false);
// 👆 控制弹窗是否打开的状态
// useState 语法:const [变量, 修改变量的函数] = useState(初始值)
// 对应 Vue:data() { return { modalOpen: false } }
// 修改时:setModalOpen(true) 相当于 this.modalOpen = true
const [draftValue, setDraftValue] = useState(value);
// 👆 草稿值:弹窗里用户正在编辑但还没确定的值
// 💡 为什么需要 draft?
// 因为弹窗里改的时候,不应该立刻影响外面的真实值,
// 只有用户点了"确定",才把草稿值同步出去。
// 这就像 Vue 里常见的"编辑弹窗"模式:
// 打开时把 props 赋值给本地 data,点确定再 $emit 出去。
// ── 计算分析结果 ─────────────────────────────
const analysis = analyzeCronExpression(draftValue, { minIntervalMinutes });
// 👆 每次渲染时,根据当前草稿值算出分析结果
// (是否合法、错误信息、未来运行时间)
// 这相当于 Vue 的 computed 计算属性:依赖变化自动重新计算
// ── 打开弹窗 ─────────────────────────────────
const handleOpen = () => {
// 打开弹窗时,把当前的真实值同步到草稿里
setDraftValue(value);
setModalOpen(true);
};
// 💡 为什么每次打开都要重新赋值?
// 因为上次打开可能改了草稿但没点确定(取消了),
// 这次重新打开应该从最新的真实值开始编辑。
// ── 点击确定 ─────────────────────────────────
const handleConfirm = () => {
if (!analysis.isValid) return;
// 👆 不合法就不让提交
onChange(draftValue);
// 👆 调用父组件传的回调,把草稿值传出去(更新真实值)
setModalOpen(false);
// 👆 关闭弹窗
};
// ── 渲染 ────────────────────────────────────
return (
<>
{/* 👆 <>...</> 是 React 的"空标签"(Fragment),
用来包裹多个元素但不产生真实的 DOM 节点
对应 Vue 里的 <template> 标签 */}
{/* 外层:输入框 + 按钮 紧凑排列 */}
<Space.Compact style={{ width: '100%' }}>
{/* 👆 Space.Compact 是 Ant Design 的组件,
让多个按钮/输入框紧贴在一起(没有间距) */}
<Input
value={value}
// 👆 显示真实值(不是草稿值,因为这是"外面"的输入框)
onChange={e => onChange(e.target.value)}
// 👆 用户直接在输入框里改,实时同步给父组件
placeholder="CRON 表达式,如 0 9 * * *"
/>
<Button onClick={handleOpen}>图形化创建</Button>
{/* 👆 点击按钮打开图形化编辑弹窗 */}
</Space.Compact>
{/* 表达式合法时,显示人性化描述 */}
{value && analyzeCronExpression(value).isValid && (
// 👆 这是 React 的条件渲染写法:条件 && <组件 />
// 对应 Vue 的 v-if="value && isValid"
<div style={{ marginTop: 4, fontSize: 12, color: '#666' }}>
{/* 👆 行内样式:外层 {} 表示 JS 表达式,内层 {} 是样式对象 */}
{cronstrue.toString(value, { locale: 'zh_CN' })}
{/* 👆 调用 cronstrue 把表达式转成中文描述
比如 "0 9 * * *" → "每天上午 09:00" */}
</div>
)}
{/* 弹窗:图形化编辑器 */}
<Modal
title="CRON 表达式编辑器"
open={modalOpen}
onOk={handleConfirm}
onCancel={() => setModalOpen(false)}
okButtonProps={{ disabled: !analysis.isValid }}
// 👆 表达式不合法时,确定按钮是禁用的
width={720}
>
{/* 弹窗内容:垂直排列的几个区域 */}
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 区域1:手动输入框 + 错误提示 */}
<div>
<div style={{ marginBottom: 8 }}>表达式:</div>
<Input
value={draftValue}
// 👆 弹窗里用的是草稿值
onChange={e => setDraftValue(e.target.value)}
status={!analysis.isValid ? 'error' : ''}
// 👆 不合法时输入框变红
/>
{!analysis.isValid && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>
{analysis.errorMessage}
{/* 显示具体错误信息 */}
</div>
)}
</div>
{/* 区域2:图形化选择器 */}
<Cron value={draftValue} onChange={setDraftValue} locale="zh-cn" />
{/* 👆 第三方 CRON 组件,双向绑定草稿值
用户在图形界面点选 → draftValue 变化 → 上面的输入框也同步更新
用户在输入框里改 → draftValue 变化 → 图形界面也同步更新
这就是"双向同步" */}
{/* 区域3:未来运行时间预览 */}
<div>
<div style={{ marginBottom: 8 }}>未来 10 次运行时间:</div>
<ul style={{ fontSize: 12, color: '#666', lineHeight: 1.8 }}>
{/* 遍历未来 10 次运行时间,渲染成列表 */}
{analysis.nextRuns.map((time, i) => (
// 👆 map 对应 Vue 的 v-for
<li key={i}>{time}</li>
// 👆 key 对应 Vue 的 :key,帮助 React 识别列表项
))}
</ul>
</div>
</Space>
</Modal>
</>
);
}
2.3 CRON 解析工具函数
🎓 小白课堂:工具函数是什么?
工具函数就是纯逻辑、不依赖界面的函数。
放在单独的文件里,哪里需要哪里引。
对应 Vue 项目里utils/目录下的函数。
好处是:可以单独测试、可以复用、组件代码更干净。
// src/components/cron-editor/cron-utils.ts
// 👆 跟组件放在同一个目录下,因为是这个组件专用的工具函数
import { parseExpression } from 'cron-parser';
// 👆 引入第三方库的解析函数
import dayjs from 'dayjs';
// 👆 引入日期处理库(比 moment 小,API 差不多)
// 👇 定义分析结果的类型
interface CronAnalysisResult {
isValid: boolean;
// 表达式是否合法
errorMessage: string;
// 错误信息(不合法时有)
nextRuns: string[];
// 未来 N 次运行时间的数组
}
// 👇 导出分析函数
export function analyzeCronExpression(
expression: string,
// 第一个参数:CRON 表达式字符串
options: { minIntervalMinutes?: number } = {}
// 第二个参数:配置选项(可选),默认是空对象
// 这里用了"解构赋值 + 默认值"的写法
): CronAnalysisResult {
// 👆 返回值类型是 CronAnalysisResult
// ── 第一层校验:空字符串 ──────────────────────
if (!expression.trim()) {
// 👆 trim() 去掉首尾空格后如果为空
return { isValid: false, errorMessage: '请输入 CRON 表达式', nextRuns: [] };
}
// ── 第二层校验:解析 + 计算 ────────────────────
try {
// 👆 try-catch 捕获解析错误(表达式格式不对会抛异常)
const interval = parseExpression(expression, { tz: 'Asia/Shanghai' });
// 👆 解析表达式,指定时区为上海(北京时间)
// interval 是一个迭代器,可以不断调 .next() 取下一次运行时间
const nextRuns: string[] = [];
// 存未来运行时间的数组
let prev: Date | null = null;
// 上一次的运行时间(用来检查间隔)
// 循环 10 次,算出未来 10 次运行时间
for (let i = 0; i < 10; i++) {
const next = interval.next();
// 👆 取下一次运行时间(返回一个对象)
const date = next.toDate();
// 转成 JS 的 Date 对象
// ── 第三层校验:最小间隔 ────────────────
if (options.minIntervalMinutes && prev) {
// 如果设置了最小间隔,且不是第一次(有 prev)
const diffMinutes = (date.getTime() - prev.getTime()) / 60000;
// 👆 计算两次之间的分钟差
// getTime() 返回毫秒数,除以 60000(60秒×1000毫秒)得到分钟
if (diffMinutes < options.minIntervalMinutes) {
return {
isValid: false,
errorMessage: `执行间隔不能小于 ${options.minIntervalMinutes} 分钟`,
nextRuns: [],
};
}
}
nextRuns.push(dayjs(date).format('YYYY-MM-DD HH:mm:ss'));
// 👆 用 dayjs 把 Date 格式化成好看的字符串,加入数组
prev = date;
// 把当前时间存为 prev,供下一次比较
}
return { isValid: true, errorMessage: '', nextRuns };
// 👆 一切正常,返回合法状态和 10 次运行时间
} catch (err) {
// 解析失败(表达式格式不对)
return {
isValid: false,
errorMessage: err instanceof Error ? err.message : '表达式格式错误',
// 👆 instanceof 是 JS 运算符,判断 err 是不是 Error 类型的实例
// 如果是,就用它的 message;否则用默认文字
nextRuns: [],
};
}
}
设计亮点:三层验证。第一层是 react-js-cron 组件本身保证格式正确;第二层是 cron-parser 解析校验;第三层是业务规则校验(最小间隔)。用户不管怎么操作,提交的一定是合法且满足业务要求的表达式。
三、模板变量系统
🎓 小白课堂:模板变量是啥?
想象你要给每天的巡检任务起标题:
- 1月15号的任务叫 “01月15日 系统巡检报告”
- 1月16号的任务叫 “01月16日 系统巡检报告”
- …
总不能每天改一次模板吧?
模板变量就是:在标题里写一个占位符(比如
{{date.MM月dd日}}),
真正生成任务时,系统会自动把占位符替换成当天的真实日期。就像你写邮件时的「{{姓名}}」占位符,群发时自动替换成每个人的名字。
周期任务的标题和描述需要支持动态变量,比如「{{date.yyyy-MM-dd}} 日巡检报告」,生成任务时会替换成当天的日期。
3.1 语法定义
// src/pages/recurring-task/syntax-options.ts
// 👆 这个文件放模板变量的"配置"——有哪些变量、各自是什么意思
// recurring = 周期性的
// 👇 定义一个模板变量长什么样
export interface TemplateVariable {
key: string;
// 变量的占位符,比如 '{{date.yyyy-MM-dd}}'
label: string;
// 中文名称,给用户看的,比如 '日期(年月日)'
example: string;
// 示例值,比如 '2024-01-15'
description: string;
// 详细描述
}
// 👇 所有可用的模板变量列表
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
// 👆 一个常量数组,存了所有变量的配置
// 类型是 TemplateVariable[],意思是"TemplateVariable 类型的数组"
// 日期类
{ key: '{{date.yyyy-MM-dd}}', label: '日期(年月日)', example: '2024-01-15', description: '任务执行当天的日期' },
{ key: '{{date.yyyy/MM/dd}}', label: '日期(年/月/日)', example: '2024/01/15', description: '斜杠格式日期' },
{ key: '{{date.MM月dd日}}', label: '日期(月日)', example: '01月15日', description: '中文月日格式' },
{ key: '{{date.yyyy年第ww周}}', label: '第几周', example: '2024年第03周', description: 'ISO 周数' },
// 时间类
{ key: '{{date.HH:mm}}', label: '时间(时分)', example: '09:30', description: '24小时制时分' },
{ key: '{{date.HH:mm:ss}}', label: '时间(时分秒)', example: '09:30:00', description: '24小时制时分秒' },
// 其他
{ key: '{{date.M}}', label: '月份(数字)', example: '1', description: '不补零的月份' },
{ key: '{{date.d}}', label: '日期(数字)', example: '15', description: '不补零的日期' },
];
// 💡 为什么要用配置数组的方式?
// 因为这样加新变量特别简单——往数组里加一项就行,
// UI 上自动会多一个选项,替换逻辑也自动支持(因为走的是正则匹配)。
// 这就是"数据驱动"的思想。
3.2 变量替换实现
🎓 小白课堂:正则表达式是什么?
正则表达式(Regular Expression,简称 RegExp)是一种用来"匹配字符串模式"的工具。
比如你想在一段文字里找出所有{{xxx}}格式的东西,就可以用正则来匹配。
对应 Vue 里,你可能用过String.prototype.replace()配合正则。
// src/pages/recurring-task/utils/template-render.ts
// 👆 模板渲染工具函数
import dayjs from 'dayjs';
// 👇 渲染模板:把模板里的变量替换成真实值
export function renderTemplate(template: string, date: Date = new Date()): string {
// 👆 两个参数:
// template = 模板字符串(含 {{date.xxx}} 占位符)
// date = 用来替换的日期,默认是当前时间
// 返回值:替换后的字符串
const d = dayjs(date);
// 👆 把 Date 包成 dayjs 对象,方便格式化
// 日期格式:{{date.xxx}} → 替换为格式化后的日期
return template.replace(/\{\{date\.([^}]+)\}\}/g, (_, format) => {
// 👆 replace + 正则表达式,核心的一行,我们拆开讲:
//
// /\{\{date\.([^}]+)\}\}/g 这个正则的意思是:
// - \{\{ → 匹配两个左花括号 {{
// ({ 在正则里有特殊含义,所以要加 \ 转义)
// - date\. → 匹配 date.(点也要转义)
// - ([^}]+) → 捕获一组:一个或多个"不是 } 的字符"
// 也就是 date. 后面、}} 前面的内容
// 比如 yyyy-MM-dd
// - \}\} → 匹配两个右花括号 }}
// - g → 全局匹配(找到所有符合的,不只是第一个)
//
// 回调函数的两个参数:
// - 第一个参数 _ 是整个匹配到的字符串(比如 '{{date.yyyy-MM-dd}}')
// 用 _ 表示"这个参数我不用"
// - 第二个参数 format 是第一个捕获组的内容(比如 'yyyy-MM-dd')
try {
// 支持中文格式:第ww周、MM月dd日 等
return d.format(format);
// 👆 直接用 dayjs 的 format 方法格式化
// 比如 d.format('yyyy-MM-dd') → '2024-01-15'
} catch {
// 如果格式化出错(比如格式字符串不合法),返回空字符串
return '';
}
});
}
3.3 语法提示组件
在标题和描述输入框旁边放一个「插入变量」按钮,点击弹出变量选择面板:
// src/pages/recurring-task/components/syntax-hint/index.tsx
// 👆 语法提示组件:点按钮弹出变量列表,点击某个变量就插入到输入框里
import { useState } from 'react';
import { Button, Popover, List } from 'antd';
// 👆 Popover = 气泡卡片(点按钮弹出一个小面板)
import { TEMPLATE_VARIABLES } from '../../syntax-options';
// 👆 引入变量配置
// 👇 Props 定义
interface Props {
onInsert: (variable: string) => void;
// 点击某个变量时的回调,把变量的 key(比如 '{{date.yyyy-MM-dd}}')传出去
// 由父组件决定怎么插入到输入框里(插入到光标位置等)
}
// 👇 组件
export function SyntaxHint({ onInsert }: Props) {
const [open, setOpen] = useState(false);
// 👆 控制气泡面板是否打开的状态
// 气泡面板的内容:一个变量列表
const content = (
<List
size="small"
dataSource={TEMPLATE_VARIABLES}
// 👆 列表的数据源(数组),对应 Vue 的 :data-source
renderItem={item => (
// 👆 渲染每一项的函数
<List.Item
style={{ cursor: 'pointer' }}
onClick={() => {
onInsert(item.key);
// 点击某一项,把变量 key 传出去
setOpen(false);
// 然后关掉面板
}}
>
<List.Item.Meta
title={<code>{item.key}</code>}
// 标题显示变量占位符(用 code 标签,等宽字体)
description={`${item.label},例:${item.example}`}
// 描述显示中文名称 + 示例
/>
</List.Item>
)}
style={{ width: 320, maxHeight: 400, overflow: 'auto' }}
// 宽度320,最高400,超出滚动
/>
);
return (
<Popover content={content} title="可用变量" trigger="click" open={open} onOpenChange={setOpen}>
{/* 👆 Popover 组件:
content = 弹出的内容
title = 弹出面板的标题
trigger = 触发方式(click = 点击触发)
open = 受控的打开状态
onOpenChange = 打开状态变化时的回调 */}
<Button size="small">插入变量</Button>
{/* 👆 被 Popover 包裹的元素,点击它就弹出面板 */}
</Popover>
);
}
四、规则列表页
🎓 小白课堂:列表页的基本套路
几乎所有后台系统的列表页都是一个套路:
- 筛选器(顶部:搜索框、下拉选择、日期范围等)
- 表格(中间:数据表格,带分页)
- 行操作(每行:查看/编辑/删除等按钮)
对应 Vue 项目里,就是一个
<el-table>+<el-form>筛选。
React + Ant Design 的套路也差不多,就是写法不一样而已。
4.1 筛选器
列表页筛选条件:
- 规则名称(模糊搜索)
- 创建类型(定时规则/页面创建)
- 状态(启用/停用/审核中)
- 创建时间范围
4.2 表格列
| 列 | 说明 |
|---|---|
| 规则 ID | 唯一标识 |
| 规则名称 | 点击进入详情 |
| 创建类型 | 定时规则 / 页面创建 |
| 状态 | 启用/停用/审核中(标签颜色区分) |
| CRON 表达式 | 附带人性化描述 |
| 创建人 | 谁建的规则 |
| 创建时间 | 时间 |
| 操作 | 详情/启用/停用/删除 |
4.3 行操作
根据状态显示不同操作:
- 启用中 → 显示「停用」「删除」
- 已停用 → 显示「启用」「删除」
- 审核中 → 显示「审核」(有权限才显示)
💡 这种"根据状态动态显示操作按钮"的设计,在 Vue 里通常用
v-if或计算属性来实现,
在 React 里就是条件渲染 + 三元表达式,思路是一样的。
五、创建规则:分步表单设计
🎓 小白课堂:为什么要用分区/分步表单?
创建一条周期规则要填的东西很多——任务标题、描述、分类、联系人、处理人、CRON表达式……
如果全部堆在一个页面上,用户看着就头大。分区表单就是把相关的字段分成一组一组,用卡片分开,视觉上更清晰。
(更复杂的场景会用"步骤条",一步一步填,下一步之前先校验上一步)对应 Vue 里的
<el-form>加多个<el-card>或<el-divider>。
创建规则采用分区表单,分为四大区域:
5.1 任务信息区
- 任务标题(必填,支持模板变量)
- 任务描述(支持模板变量)
- 任务分类(一级/二级联动)
- 涉及系统(多选)
- 优先级(P1-P4)
- 影响范围 / 紧急程度 / 是否影响可用性
5.2 联系人区
- 联系人姓名
- 联系电话
5.3 处理人区
- 负责人(单选)
- 参与人(多选)
5.4 定时规则区
- 规则名称(必填)
- 规则备注
- CRON 表达式编辑器(核心组件)
- 是否跳过法定节假日(开关)
// src/pages/recurring-task/create/index.tsx
// 👆 创建周期任务的页面组件
import { useState } from 'react';
// 👆 别忘了引入 useState(原代码里漏了,实际项目必须引入)
import { Form, Button, Space, Card, message } from 'antd';
// 👆 从 antd 引入组件(原代码里漏了 message,补上)
import { useNavigate } from 'react-router-dom';
// 👆 路由跳转的 Hook(原代码里用了 navigate 但没引入,补上)
// 对应 Vue 里的 this.$router.push()
import { TaskInfoSection } from './components/task-info-section';
import { ContactSection } from './components/contact-section';
import { HandlerSection } from './components/handler-section';
import { RuleSection } from './components/rule-section';
// 👆 四个分区组件,每个负责一块表单区域
import { createRecurringRule } from '../api';
// 👆 引入创建规则的 API 函数
export function CreateRecurringTaskPage() {
const [form] = Form.useForm();
// 👆 Ant Design Form 的 Hook:创建一个 form 实例
// 对应 Vue 里的 this.$refs.formRef 或 form 实例
// 用这个 form 实例可以调用 validateFields(校验)、setFieldsValue(设值)等
const [submitting, setSubmitting] = useState(false);
// 👆 提交中状态(用来防止重复提交、显示 loading)
const navigate = useNavigate();
// 👆 获取路由跳转函数
// ── 提交处理 ─────────────────────────────────
const handleSubmit = async () => {
// 👆 async 表示这是一个异步函数
const values = await form.validateFields();
// 👆 先校验整个表单,如果校验失败会抛异常(被 catch 到)
// 校验通过就拿到所有表单值
setSubmitting(true);
// 👆 开始提交,设为提交中
try {
await createRecurringRule(values);
// 👆 调用 API,把表单值发给后端
message.success('创建成功,等待审核');
// 👆 成功提示
navigate('/recurring-task');
// 👆 跳转到规则列表页
} finally {
// 👆 finally 不管成功失败都会执行
setSubmitting(false);
// 提交结束,恢复状态
}
};
// 💡 为什么错误不 catch?
// 因为 requestApi 封装里可能已经统一处理了错误提示,
// 这里只需要保证 submitting 状态被重置就行。
// 当然,如果你需要自定义错误处理,也可以加 catch。
// ── 渲染 ────────────────────────────────────
return (
<div className="create-page">
{/* 分区1:任务信息 */}
<Card title="任务信息">
<TaskInfoSection form={form} />
{/* 👆 把 form 实例传给子组件,这样子组件里的表单项会注册到同一个 form 上 */}
</Card>
{/* 分区2:联系人 */}
<Card title="联系人">
<ContactSection form={form} />
</Card>
{/* 分区3:处理人 */}
<Card title="处理人">
<HandlerSection form={form} />
</Card>
{/* 分区4:定时规则 */}
<Card title="定时规则">
<RuleSection form={form} />
</Card>
{/* 底部操作栏 */}
<div className="action-bar">
<Space>
<Button onClick={() => navigate(-1)}>取消</Button>
{/* 👆 navigate(-1) = 返回上一页,对应 Vue 的 this.$router.back() */}
<Button type="primary" loading={submitting} onClick={handleSubmit}>
创建
</Button>
{/* 👆 主按钮,loading 状态时显示加载动画,且不可点击 */}
</Space>
</div>
</div>
);
}
六、规则详情页
🎓 小白课堂:详情页是啥?
列表页是"看一堆数据",详情页是"看一条数据的完整信息"。
就像你在淘宝搜索结果页(列表)点进一个商品(详情)。详情页通常的结构:
- 顶部:标题 + 状态 + 操作按钮
- 中间:基本信息、关联信息等(可能用 Tab 切换)
- 底部:可能有操作记录、历史等
6.1 详情结构
规则详情页
├── 顶部:规则名称 + 状态标签 + 操作按钮(编辑/启用/停用/删除)
├── 基本信息区
│ ├── 规则 ID、创建人、创建时间
│ ├── CRON 表达式 + 人性化描述
│ └── 是否跳过节假日
├── 任务模板区
│ ├── 标题、描述(模板变量原样展示)
│ ├── 分类、系统、优先级
│ ├── 负责人、参与人
│ └── 联系人
└── 执行历史 Tab 页
└── 表格:执行时间 / 状态 / 生成的任务 / 结果说明
6.2 执行历史
执行历史是规则详情的重要组成部分,用户可以看到每条规则的执行情况:
// 执行历史项类型
interface ExecutionHistoryRecord {
id: number;
// 记录的唯一 ID
job_id: number;
// 任务作业 ID(后端调度系统里的 ID)
task_id: string; // 生成的任务 ID(成功时才有)
// 👆 执行成功时,会生成一条任务,这里存那个任务的 ID
// 点一下就能跳转到对应的任务详情页
status: 'success' | 'failed';
// 执行状态:成功 / 失败
// 👆 这是 TypeScript 的"联合类型",值只能是这两个字符串之一
// 对应 Vue 里 props 的 validator 校验
result: string; // 结果说明
executed_at: number; // 执行时间(Unix 时间戳)
created_at: number;
// 记录创建时间
}
行操作:点击「查看任务」跳转到对应任务详情,URL 带 hash 参数标记来源。
💡 hash 参数是什么?
就是 URL 里#后面的部分,比如task/TASK-001#from=recurring。
带个标记,任务详情页就知道"用户是从周期任务历史过来的",
可以做一些特殊处理(比如显示"返回周期任务"按钮)。
七、审核与启停机制
🎓 小白课堂:为什么要有审核?
周期任务是"自动执行"的——一旦设好,系统就会默默地按时生成任务。
如果有人手抖设错了(比如设成每分钟执行一次),可能会产生大量垃圾任务,甚至把系统搞挂。所以需要一个审核机制:新规则创建后,需要管理员审核通过了才能真正启用。
就像你申请开通某个权限,需要领导审批一样。
7.1 状态流转
创建 → 审核中 → 启用 → 停用
↓
审核拒绝
- 创建后默认进入「审核中」状态
- 审核通过后变为「启用」,开始按 CRON 定时执行
- 审核拒绝后保持「审核拒绝」状态,可编辑后重新提交
💡 状态机(State Machine):
这是一个典型的"状态机"模型——有若干个状态,每个状态只能流转到特定的下一个状态。
比如"审核中"不能直接变"停用",必须先通过变成"启用",再停用。
好处是逻辑清晰,不会出现奇怪的中间状态。
7.2 权限规则
- 创建者可以编辑、删除自己的规则
- 审核角色才能执行审核操作
- 启用/停用需要编辑权限
💡 按钮级权限控制:
在 Vue 里通常用自定义指令v-permission或v-if+ 权限判断函数来控制。
在 React 里也是类似的思路——写一个权限判断函数,用条件渲染控制按钮显示不显示。
八、设计亮点总结
体验层面
- 三层 CRON 编辑:图形化 + 手动输入 + 运行预览,新手老手都能用
- 模板变量一键插入:不用背语法,点按钮就能插入变量
- 执行历史可追溯:每次执行都有记录,成功失败一目了然
- 分区表单清晰:四大区域从上到下,创建流程清晰
工程层面
- CRON 编辑器独立封装:复用到其他需要定时的场景
- 模板变量系统可扩展:加新变量只需往数组里加一项
- 分区组件化:每个分区一个组件,创建和编辑共用
- 状态与权限联动:不同状态显示不同操作,权限控制到按钮级
架构层面
- 规则与模板分离:规则管"什么时候执行",模板管"生成什么样的任务"
- 执行历史独立:每次执行单独记录,便于排查和审计
- 审核机制前置:新规则必须审核通过才能生效,防止误操作
九、可扩展方向
- 执行记录统计:统计每条规则的成功率、平均耗时
- 失败告警:执行失败时发通知提醒负责人
- 更多模板变量:支持用户自定义变量、支持上一执行结果作为变量
- 手动触发:支持手动立即执行一次,方便调试
- 日历视图:用日历视图展示未来执行时间,更直观
- 任务模板版本:修改模板时保留历史版本,可回滚
写在最后
周期任务这个功能看起来简单——不就是定个时自动建任务嘛。但真做起来,从 CRON 编辑器的体验、模板变量的设计、执行历史的追踪,到审核和启停的机制,每一块都有不少细节。
核心的设计思路还是那句话:把复杂的东西封装好,给用户最简单的操作。CRON 表达式很复杂,但用户可以用图形化选择器;模板变量很灵活,但用户可以点按钮插入。背后的复杂度由系统承担,用户只需要关心业务。
希望这篇文章对你做周期任务类功能有帮助。有想法欢迎在评论区交流 👋
关于本文:基于真实生产项目脱敏整理,技术栈为 React 19 + TypeScript + Vite + Ant Design。文中代码为教学目的做了精简,实际项目中请根据具体需求调整。
📝 小白复习要点
看完这份文档,重点理解这些概念和技术:
| 概念 | 一句话总结 | Vue 2 对应物 |
|---|---|---|
| CRON 表达式 | 用5个字段表示"什么时候执行"的字符串 | —(通用概念) |
| 受控组件 | 值由父组件管理,子组件通过 onChange 上报 | v-model(value + input 事件) |
| 草稿值(draft) | 弹窗里临时存的值,确定后才同步出去 | 编辑弹窗里的本地 data |
| 模板变量 | 用 {{xxx}} 占位符,运行时替换成真实值 | —(通用概念,类似 i18n 的插值) |
| 正则 replace | 用正则匹配 + 回调函数替换字符串 | String.replace() 一样 |
| 分区表单 | 把表单字段分组,用卡片分开展示 | 多个 el-card 或分步表单 |
| try-catch | 捕获可能出错的代码,不让程序崩 | JS 原生,Vue 里也一样用 |
| 状态机 | 一组状态 + 流转规则,防止乱跳 | —(通用设计模式) |
| 数据驱动 | 用配置数组控制 UI,加东西改配置就行 | —(通用设计思想) |
| 行内样式 style | React 里用 style={{...}} 对象形式 | Vue 里的 :style="{...}" |
如果你这些都理解了,恭喜你——你已经掌握了周期任务模块的核心实现思路!🎉