周期任务功能完整实现:CRON 编辑器 + 模板变量 + 执行历史

2 阅读22分钟

📌 写给熟悉 Vue2.3 的你:

这篇文档讲的是「周期任务」功能的完整实现。碰到 React 的新概念、TypeScript 的新语法,我都会用 Vue 类比、用大白话给你讲明白。

先记住一个最重要的类比(跟之前的教程一致):

Vue 2 的概念React 里对应的东西一句话解释
data() 里的数据useState()定义响应式变量,变了就重新渲染
computed 计算属性函数里直接算 / useMemo根据已有数据算出新值
methods 方法函数组件里定义的函数处理点击等交互逻辑
mounted 生命周期useEffect(() => {}, [])组件第一次渲染完后执行
watch 侦听useEffect(() => {}, [依赖])某个值变了就执行
props 父传子函数的参数 Props一模一样,父组件传进来的数据
$emit 子传父父传一个回调函数当 propsReact 没有 $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:00
  • 0 9 * * 1 = 每周一早上 9:00
  • 30 8 1 * * = 每月 1 号早上 8:30

其中 * 表示"每"的意思。

这个东西对新手来说很不友好(你一眼能看懂 */5 9-17 * * 1-5 是什么意思吗?),所以我们要做一个图形化编辑器,让用户点选就行,不用背语法。

CRON 编辑器是周期任务最核心的交互组件。让用户手写 0 9 * * * 这种表达式体验很差,所以我们做了三层:

  1. 图形化选择器:可视化选择分/时/日/月/周
  2. 手动输入框:高级用户直接写表达式,和图形化双向同步
  3. 运行时间预览:实时展示未来 10 次执行时间

2.1 技术选型

库用途
react-js-cron图形化 CRON 选择器组件
cron-parser解析 CRON 表达式,计算未来运行时间
cronstrueCRON 表达式转人性化中文描述

💡 为什么用三个库?
各司其职:一个管图形界面、一个管解析计算、一个管文字描述。
这就是前端开发的常态——站在巨人的肩膀上,不用什么都自己写。

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>
  );
}

四、规则列表页

🎓 小白课堂:列表页的基本套路

几乎所有后台系统的列表页都是一个套路:

  1. 筛选器(顶部:搜索框、下拉选择、日期范围等)
  2. 表格(中间:数据表格,带分页)
  3. 行操作(每行:查看/编辑/删除等按钮)

对应 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>
  );
}

六、规则详情页

🎓 小白课堂:详情页是啥?

列表页是"看一堆数据",详情页是"看一条数据的完整信息"。
就像你在淘宝搜索结果页(列表)点进一个商品(详情)。

详情页通常的结构:

  1. 顶部:标题 + 状态 + 操作按钮
  2. 中间:基本信息、关联信息等(可能用 Tab 切换)
  3. 底部:可能有操作记录、历史等

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 里也是类似的思路——写一个权限判断函数,用条件渲染控制按钮显示不显示。


八、设计亮点总结

体验层面

  1. 三层 CRON 编辑:图形化 + 手动输入 + 运行预览,新手老手都能用
  2. 模板变量一键插入:不用背语法,点按钮就能插入变量
  3. 执行历史可追溯:每次执行都有记录,成功失败一目了然
  4. 分区表单清晰:四大区域从上到下,创建流程清晰

工程层面

  1. CRON 编辑器独立封装:复用到其他需要定时的场景
  2. 模板变量系统可扩展:加新变量只需往数组里加一项
  3. 分区组件化:每个分区一个组件,创建和编辑共用
  4. 状态与权限联动:不同状态显示不同操作,权限控制到按钮级

架构层面

  1. 规则与模板分离:规则管"什么时候执行",模板管"生成什么样的任务"
  2. 执行历史独立:每次执行单独记录,便于排查和审计
  3. 审核机制前置:新规则必须审核通过才能生效,防止误操作

九、可扩展方向

  1. 执行记录统计:统计每条规则的成功率、平均耗时
  2. 失败告警:执行失败时发通知提醒负责人
  3. 更多模板变量:支持用户自定义变量、支持上一执行结果作为变量
  4. 手动触发:支持手动立即执行一次,方便调试
  5. 日历视图:用日历视图展示未来执行时间,更直观
  6. 任务模板版本:修改模板时保留历史版本,可回滚

写在最后

周期任务这个功能看起来简单——不就是定个时自动建任务嘛。但真做起来,从 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,加东西改配置就行—(通用设计思想)
行内样式 styleReact 里用 style={{...}} 对象形式Vue 里的 :style="{...}"

如果你这些都理解了,恭喜你——你已经掌握了周期任务模块的核心实现思路!🎉