定时任务前端底座:接口约定的类型、api.schedules、页面用的纯函数和文案

types.ts 末尾一段写定时任务的接口约定(Schedule、Fire、预览、变量、立即运行、触发历史、Webhook 测试),字段名和形状照前后端共用的约定,不各自发挥;运行列表的 schedule_id 用同名接口合并补在这一段,不去改上面 Run 的定义。

api/schedules.ts 挂上全部接口:列表、详情、新建、修改(只带改了的字段和读到的版本)、删除、预览(可取消,丢掉过时的请求)、变量、立即运行、触发历史分页、发送测试消息。

pages/schedules/model.ts 是不碰 React 的纯函数:
- 频率预设和 cron 互转:每天 / 每周(连着三天以上写区间)/ 每月 / 每隔几小时(只给 24 的约数,*/5 在跨天处会断)/ 自定义;填得不完整时返回空串,不拿猜出来的频率顶上;认不出的 cron 一律算自定义、原文照放
- 按定时任务自己的时区写时间(Intl 取各段再拼中文,周几、偏移照算),本机时区另给;距下次运行先取整再判档
- 表单和请求体互转:修改时和打开时的表单比,只交改了的字段(cron 写法不同但人没动不算改);Webhook 按约定地址省略即不改、空串即删除,密钥省略即不改、空串即清除,交 Webhook 时格式和事件总是一起交
- 表单的问题按字段给出,写在对应的输入框下

terms.ts 的 SCHEDULE_TEXT 写全定时任务页的文案;占位页的两条先留着,换成真页面时删掉。「钉钉」是 Webhook 的产品名,会被「钉住」那条规则误报,在 copy-allow.json 里登记一条例外并写明理由。
This commit is contained in:
ThearchyHelios
2026-10-05 08:56:02 +08:00
parent 26595b70f7
commit 4deffd62f7
5 changed files with 951 additions and 3 deletions
+35 -2
View File
@@ -1,7 +1,40 @@
/**
* 定时任务的接口(/api/schedules)。现在是空的,定时任务的工作包往里加,挂在 api.schedules 上。
* 定时任务的接口(/api/schedules),挂在 api.schedules 上。字段名和形状照前后端共用的接口约定(types.ts 末尾那一段)。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*
* 错误都是 ApiError,message 是服务端给的中文 detail:修改时版本不符回 409,预览非法回 422 且 field 指明是
* cron / timezone / input_template 哪一项(ApiError.field)。老服务端没有这些接口,回 404 且没有 code
*/
export const schedules = {}
import type {
Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult,
ScheduleVariable, ScheduleWebhookTest,
} from '../types'
import { del, get, patch, post, qs, request, type RequestOptions } from './http'
const at = (id: string) => `/schedules/${encodeURIComponent(id)}`
export const schedules = {
list: (opts?: RequestOptions) => get<{ items: Schedule[] }>('/schedules', opts),
get: (id: string, opts?: RequestOptions) => get<Schedule>(at(id), opts),
create: (body: ScheduleCreate) => post<Schedule>('/schedules', body),
/** 只带改了的字段和读到的版本;Webhook 的地址省略表示不改、空串表示删除 */
update: (id: string, body: SchedulePatch) => patch<Schedule>(at(id), body),
remove: (id: string) => del(at(id)),
/**
* 接下来几次触发时间和渲染出的输入,只算不存。signal 用来丢掉过时的请求:频率还在改、又发了新的一次时,
* 上一次的答复不再要(取消照旧抛 AbortError)
*/
preview: (body: SchedulePreviewRequest, opts?: { signal?: AbortSignal }) =>
request<SchedulePreview>('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }),
/** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */
variables: (opts?: RequestOptions) => get<ScheduleVariable[]>('/schedules/variables', opts),
/** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */
run: (id: string) => post<ScheduleRunResult>(`${at(id)}/run`),
/** 触发历史,新的在前。before 是上一页给的 next_before */
fires: (id: string, page?: { limit?: number; before?: string | null }, opts?: RequestOptions) =>
get<ScheduleFirePage>(`${at(id)}/fires${qs(page)}`, opts),
/** 用已保存的 Webhook 设置发一条测试消息(不用页面上还没保存的) */
testWebhook: (id: string) => post<ScheduleWebhookTest>(`${at(id)}/webhook/test`),
}
+294 -1
View File
@@ -2732,12 +2732,305 @@ export const COPILOT_CONTEXT_TEXT = {
/** 尚未完成的功能在界面上的统一说法 */
const PENDING_TEXT = '功能开发中'
/** 定时任务:导航、命令面板和定时任务页 */
/**
* 定时任务:导航、命令面板、定时任务页(pages/schedules)和记录页里的「定时」标记。
*
* 触发、跳过、补发各有固定说法:「触发」是到点(或手动)的那一下,「发起」是由它开始一次运行;跳过分「上次未结束」
* 和「错过时间」两种,不笼统写「失败」。时间都按定时任务自己的时区写,悬停另给本机时间
*/
export const SCHEDULE_TEXT = {
title: '定时任务',
hint: '按设定的周期自动发起工作流运行',
pending: PENDING_TEXT,
pendingHint: '定时任务尚未开放,完成后可在此处管理',
// ---- 列表
create: '新建定时任务',
emptyTitle: '还没有定时任务',
emptyBody: '定时任务按设定的周期自动发起工作流运行,适合日报、周报这类定期产出的结果。选择工作流、设定频率和输入即可创建,每次触发都记入触发历史。',
unsupported: '服务端暂不支持定时任务',
unsupportedHint: '当前服务端没有定时任务接口,请升级服务端后再使用',
listLabel: '定时任务列表',
formalChip: '正式运行',
pausedChip: '已暂停',
nextRun: '下次运行',
lastFire: '上次触发',
neverFired: '尚未触发',
/** 暂停中的没有下一次 */
nextPaused: '暂停中,不会触发',
/** 启用着却算不出下一次(如 2 月 30 日) */
nextNone: '往后不会再触发',
/** 计划时间已过、还没有这一次的触发记录:调度可能没开,或者正在发起 */
nextOverdue: '已到触发时间',
/** 悬停提示:按哪个时区、本机是几点 */
zoneTitle: (when: string, tz: string) => `${when}(${tz})`,
localTitle: (when: string) => `本机时间 ${when}`,
enable: '启用',
enableLabel: (name: string) => `启用定时任务「${name}」`,
enabledOk: (name: string) => `已启用「${name}」`,
pausedOk: (name: string) => `已暂停「${name}」,到点不再发起运行`,
runNow: '立即运行',
runNowLabel: (name: string) => `立即运行定时任务「${name}」`,
edit: '编辑',
editLabel: (name: string) => `编辑定时任务「${name}」`,
deleteLabel: (name: string) => `删除定时任务「${name}」`,
deleteTitle: (name: string) => `删除定时任务「${name}」?`,
deleteConsequences: ['不再按计划发起运行,删除后不可恢复', '已发起的运行及其记录不受影响'],
deleteConfirm: '删除定时任务',
deleted: (name: string) => `已删除定时任务「${name}」`,
/** 写操作遇到 409:服务端的原话后面接这句 */
conflictReloaded: '已重新载入最新设置,请确认后再操作',
// ---- 立即运行的结果
runStarted: '已发起运行',
runSkipped: '上一次运行尚未结束,本次已按重叠策略跳过',
runFailed: (reason: string | null) => (reason ? `未能发起运行:${reason}` : '未能发起运行'),
openRun: '查看运行',
// ---- 触发
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record<import('../types').ScheduleFireKind, string>,
fireKindHint: {
scheduled: '到点按计划触发',
manual: '手动「立即运行」',
misfire: '错过触发时间后,在补发宽限内恢复时补发的一次',
} as Record<import('../types').ScheduleFireKind, string>,
fireStatus: {
started: '已发起',
skipped_overlap: '上次未结束已跳过',
skipped_misfire: '错过已跳过',
failed_to_start: '发起失败',
} as Record<import('../types').ScheduleFireStatus, string>,
fireStatusHint: {
started: '已发起运行',
skipped_overlap: '上一次运行还没有结束,按重叠策略跳过了这一次',
skipped_misfire: '服务停机等原因错过了触发时间,且超出补发宽限,这一次没有发起',
failed_to_start: '到点发起时被拒绝,原因见本条记录',
} as Record<import('../types').ScheduleFireStatus, string>,
event: { finished: '运行结束', failed: '运行失败', awaiting_approval: '等待审批' } as Record<import('../types').ScheduleEvent, string>,
noticeStatus: { ok: '已发送', failed: '发送失败' } as Record<'ok' | 'failed', string>,
// ---- 详情
tabs: { settings: '设置', history: '触发历史' } as Record<'settings' | 'history', string>,
back: '返回列表',
notFound: '定时任务不存在或已被删除',
notFoundHint: '请返回列表查看现有的定时任务',
// ---- 表单:基本
newTitle: '新建定时任务',
name: '名称',
namePlaceholder: '例如:门店周报',
workflow: '工作流',
workflowPlaceholder: '搜索并选择工作流',
workflowNone: '没有匹配的工作流',
workflowEmpty: '还没有工作流,请先在编排页创建并保存。',
toStudio: '去编排',
/** 定时任务指向的工作流已经不在列表里 */
workflowGone: (name: string) => `工作流「${name}」已不存在,请重新选择`,
workflowDraft: '草稿',
workflowVersion: (status: string, v: number) => `${status} v${v}`,
// ---- 表单:频率
freq: '频率',
preset: { daily: '每天', weekly: '每周', monthly: '每月', hourly: '每隔几小时', custom: '自定义 cron' } as Record<
'daily' | 'weekly' | 'monthly' | 'hourly' | 'custom', string>,
time: '时间',
weekdays: '星期',
weekdayShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] as readonly string[],
monthday: '日期',
monthdayOption: (d: number) => `${d} 日`,
monthdayHint: '没有这一天的月份不触发',
everyHours: '间隔',
everyHoursOption: (n: number) => `每 ${n} 小时`,
minute: '第几分',
hourlyHint: (n: number, m: number) => `从 0 点起每 ${n} 小时一次,每次在第 ${m} 分`,
cron: 'cron 表达式',
cronHint: '五段,依次为分、时、日、月、星期,例如 0 9 * * 1-5 表示工作日 09:00',
timezone: '时区',
timezoneHint: '触发时间按这个时区计算',
timezoneLocal: '本机',
timezoneLabel: {
'Asia/Shanghai': '北京', 'Asia/Hong_Kong': '香港', 'Asia/Taipei': '台北', 'Asia/Singapore': '新加坡',
'Asia/Tokyo': '东京', 'Asia/Seoul': '首尔', 'Asia/Kolkata': '印度', 'Asia/Dubai': '迪拜', 'Europe/London': '伦敦',
'Europe/Paris': '巴黎', 'Europe/Berlin': '柏林', 'America/New_York': '纽约', 'America/Chicago': '芝加哥',
'America/Los_Angeles': '洛杉矶', UTC: '协调世界时',
} as Record<string, string>,
// ---- 表单:预览
preview: '接下来 5 次触发',
previewLoading: '正在计算…',
previewIdle: '填好频率后在此预览触发时间',
previewNone: '按这个频率往后不会触发',
previewFailed: '无法预览触发时间',
previewZone: (tz: string) => `按 ${tz} 计算`,
// ---- 表单:运行类别
runClass: '运行类别',
formal: '正式运行',
formalHint: '从已发布版本发起,结果出具、可审计。工作流须已发布',
formalVersion: (v: number) => `每次按当时的发布版本发起,当前为 v${v}`,
exploratory: '探索运行',
exploratoryHint: '使用工作流当前保存的内容,结果不出具',
notPublished: '这个工作流尚未发布:到点时无法发起正式运行,会记为发起失败。请先发布,或改用探索运行',
// ---- 表单:输入
inputs: '输入',
inputsHint: '可填固定值或模板,模板中的变量按触发时间计算;留空的字段按工作流的默认值',
inputsPick: '选择工作流后在此填写输入',
inputsNone: '这个工作流没有输入字段',
inputsLoading: '正在读取输入字段…',
inputsFailed: '无法读取输入字段',
inputsFromVersion: (v: number) => `字段取自已发布的 v${v}`,
inputsFromDraft: '字段取自工作流当前保存的内容',
required: '必填',
requiredNoDefault: '必填且没有默认值:留空时到点会发起失败',
defaultValue: (v: string) => `默认:${v}`,
variables: '插入变量',
variablesClose: '收起变量',
variablesLabel: (field: string) => `插入到「${field}」的变量`,
variablesEmpty: '服务端没有提供可用的变量',
variableTitle: (name: string, example: string) => (example ? `${name},例如 ${example}` : name),
rendered: (v: string) => `下次触发时为:${v}`,
renderedEmpty: '下次触发时为空',
staleField: '工作流中已没有这个字段',
removeField: '移除',
// ---- 表单:重叠与补发
overlap: '重叠与补发',
overlapText: '上一次运行还没有结束时,跳过这一次,记入触发历史',
misfire: '错过触发时间',
misfireHint: '服务停机等原因错过触发时间时,在宽限内恢复则补发一次,超过则跳过',
misfireOption: (span: string) => `${span}内补发`,
span: { day: (n: number) => `${n} 天`, hour: (n: number) => `${n} 小时`, minute: (n: number) => `${n} 分钟`, second: (n: number) => `${n} 秒` },
enabledOnCreate: '创建后立即启用',
// ---- 表单:Webhook
webhook: 'Webhook 通知',
webhookOn: '运行有结果时发送 Webhook 通知',
webhookFormat: '格式',
format: { generic: '通用', feishu: '飞书', wecom: '企业微信', dingtalk: '钉钉' } as Record<
import('../types').ScheduleWebhookFormat, string>,
formatHint: {
generic: '以 JSON 发送到指定地址',
feishu: '飞书群机器人的 Webhook 地址',
wecom: '企业微信群机器人的 Webhook 地址',
dingtalk: '钉钉群机器人的 Webhook 地址',
} as Record<import('../types').ScheduleWebhookFormat, string>,
urlPlaceholder: {
generic: 'https://example.com/hooks/agentlab',
feishu: 'https://open.feishu.cn/open-apis/bot/v2/hook/…',
wecom: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…',
dingtalk: 'https://oapi.dingtalk.com/robot/send?access_token=…',
} as Record<import('../types').ScheduleWebhookFormat, string>,
url: '地址',
urlSaved: '已保存的地址',
urlSavedHint: '完整地址已加密保存,只显示主机和末 4 位',
urlReplace: '替换地址',
urlKeep: '保留原地址',
webhookRemove: '删除 Webhook',
webhookRemoving: '保存后将删除这个 Webhook',
webhookUndoRemove: '撤销删除',
secret: '签名密钥',
secretHint: {
generic: '可选。填写后每条消息带签名,接收端可据此校验来源',
feishu: '可选。机器人开启了签名校验时填写',
wecom: '企业微信群机器人不使用签名,留空即可',
dingtalk: '可选。机器人开启了加签时填写',
} as Record<import('../types').ScheduleWebhookFormat, string>,
secretSet: '已设置',
secretReplace: '替换',
secretClear: '清除',
secretClearing: '保存后将清除签名密钥',
secretUndo: '撤销',
secretNew: '新的签名密钥',
events: '通知哪些事件',
test: '发送测试消息',
testing: '正在发送…',
testNeedSave: '保存后才能发送测试消息',
testDirty: '测试使用已保存的设置,请先保存修改',
testOk: (status: number | null, message: string) => `测试消息已送达${status != null ? `(HTTP ${status})` : ''}${message ? `:${message}` : ''}`,
testFailed: (status: number | null, message: string) => `测试消息发送失败${status != null ? `(HTTP ${status})` : ''}${message ? `:${message}` : ''}`,
// ---- 通知里的运行链接(设置 notify 组,所有定时任务共用)
linkBase: '通知中的运行链接',
linkBaseValue: (url: string) => `按 ${url} 拼接`,
linkBaseUnset: '未设置服务访问地址,通知中不附带运行链接',
linkBaseEdit: '修改',
linkBaseSet: '设置',
linkBaseLabel: '服务访问地址',
linkBaseHint: '收件人打开运行链接用的地址,例如 https://agentlab.example.com。所有定时任务共用',
linkBasePlaceholder: 'https://agentlab.example.com',
linkBaseInvalid: '请输入以 http:// 或 https:// 开头的地址',
linkBaseSaved: '已保存服务访问地址',
linkBaseCleared: '已清除服务访问地址',
linkBaseFailed: '无法读取服务访问地址',
// ---- 表单:保存
save: '保存',
createSubmit: '创建',
discard: '撤销修改',
retry: '重试',
saving: '正在保存…',
cancel: '取消',
saved: '已保存',
created: (name: string) => `已创建定时任务「${name}」`,
missing: (items: string) => `还需处理:${items}`,
previewBlocks: '请先修正频率、时区或输入中标出的问题',
saveFailed: '保存失败',
conflict: '保存失败:这个定时任务已在别处被修改,请重新载入后再修改',
reload: '重新载入',
reloadTitle: '重新载入最新设置?',
reloadConsequences: ['页面上未保存的修改将丢失'],
reloadConfirm: '放弃修改并重新载入',
leaveTitle: '定时任务有未保存的修改',
leaveConsequences: ['离开后,这些修改将丢失'],
leaveConfirm: '放弃修改',
// ---- 表单的问题(写在对应字段下)
problem: {
name: '请填写名称',
workflow: '请选择工作流',
timezone: '请选择时区',
cronEmpty: '请填写 cron 表达式',
cronFields: 'cron 表达式应为五段,以空格分隔',
minute: '请填写 0–59 之间的分钟',
time: '请填写时间',
weekdays: '至少选择一天',
url: '请填写 Webhook 地址',
urlInvalid: '请输入以 http:// 或 https:// 开头的地址',
secret: '请填写新的签名密钥',
events: '至少选择一个事件',
},
/** 「还需处理」里各项的叫法 */
problemField: {
name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', url: 'Webhook 地址', secret: '签名密钥', events: '通知事件',
} as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'url' | 'secret' | 'events', string>,
// ---- 相对时间(将来)
until: {
soon: '不到 1 分钟后',
minutes: (n: number) => `${n} 分钟后`,
hours: (n: number) => `${n} 小时后`,
days: (n: number) => `${n} 天后`,
},
// ---- 触发历史
history: '触发历史',
historyHint: '每次触发都记在这里,包括跳过和发起失败的',
historyEmpty: '还没有触发记录',
historyEmptyHint: '到点后的每次触发都会记在这里,包括跳过和发起失败的',
historyRefresh: '刷新',
fireAt: '计划时间',
fireAtManual: '触发时间',
reason: '原因',
noRun: '未发起运行',
notified: '通知',
loadMore: '加载更多',
allLoaded: '已全部加载',
shown: (n: number) => `已显示 ${n} 条`,
// ---- 记录页的定时标记
runTag: '定时',
runTagTitle: (name?: string | null) => (name ? `由定时任务「${name}」发起,点击查看` : '由定时任务发起,点击查看'),
}
/** 运行对比:记录页里两次运行的对照。配方前后对照另有 COMPARE_TEXT,别混用 */
+458
View File
@@ -0,0 +1,458 @@
/**
* 定时任务页的纯函数:频率预设和 cron 互转、按定时任务的时区写时间、表单和请求体互转、表单的问题。
* 不碰 React、不发请求,scripts/check-schedules.mjs 直接借 vite 转译后调用它们。
*
* 时间一律按定时任务自己的时区写(「每周一 09:00(Asia/Shanghai)」说的就是那个时区的 09:00),本机时区不同时
* 在悬停提示里另给本机时间:只按本机写,人在外地打开就会以为周报改成了凌晨三点发。
*/
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type {
Schedule, ScheduleCreate, ScheduleEvent, SchedulePatch, ScheduleRunClass, ScheduleWebhookFormat, ScheduleWebhookIn,
} from '../../types'
// ---------------------------------------------------------------------------
// 频率预设 ⇄ cron
// ---------------------------------------------------------------------------
export type FreqPreset = 'daily' | 'weekly' | 'monthly' | 'hourly' | 'custom'
export const FREQ_PRESETS: readonly FreqPreset[] = ['daily', 'weekly', 'monthly', 'hourly', 'custom']
/**
* 「每隔几小时」只给 24 的约数:*/5 这种在跨天处会断成 4 小时一次(20 点之后下一次是 0 点),
* 名义上的「每 5 小时」就不成立了。要这种的写自定义 cron
*/
export const HOUR_STEPS: readonly number[] = [1, 2, 3, 4, 6, 8, 12]
/** 星期几按周一在前排(0 是周日,和 cron 一致) */
export const WEEKDAY_ORDER: readonly number[] = [1, 2, 3, 4, 5, 6, 0]
export interface Freq {
preset: FreqPreset
/** 每天、每周、每月的时刻,HH:MM(24 小时制) */
time: string
/** 每周的星期几:0 是周日、1 是周一……6 是周六 */
weekdays: number[]
/** 每月的几号(1–31) */
monthday: number
/** 每隔几小时(HOUR_STEPS 之一) */
everyHours: number
/** 每隔几小时时,在每次的第几分(0–59) */
minute: number
/** 自定义 cron 的原文 */
cron: string
}
export const DEFAULT_FREQ: Freq = {
preset: 'daily', time: '09:00', weekdays: [1], monthday: 1, everyHours: 1, minute: 0, cron: '',
}
const pad2 = (n: number) => String(n).padStart(2, '0')
/** 「09:30」→ [9, 30];不是合法的钟点返回 null */
export function parseTime(text: string): [number, number] | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim())
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
return h <= 23 && min <= 59 ? [h, min] : null
}
/** 星期几的那一段:连着三天以上写成区间(1-5),否则逐个列出(0,6);一周七天都选了写 * */
export function weekdayField(days: number[]): string {
const set = [...new Set(days.map((d) => (d === 7 ? 0 : d)))].filter((d) => d >= 0 && d <= 6).sort((a, b) => a - b)
if (set.length === 7) return '*'
const out: string[] = []
for (let i = 0; i < set.length;) {
let j = i
while (j + 1 < set.length && set[j + 1] === set[j] + 1) j++
if (j - i >= 2) out.push(`${set[i]}-${set[j]}`)
else for (let k = i; k <= j; k++) out.push(String(set[k]))
i = j + 1
}
return out.join(',')
}
/**
* 预设生成的 cron。填得不完整(钟点没填、每周一天都没选、自定义是空的)时返回空串:
* 这时不预览、不能保存,由 freqProblem 说缺什么,不拿一个猜出来的频率顶上
*/
export function cronOf(f: Freq): string {
if (f.preset === 'custom') return f.cron.trim().split(/\s+/).filter(Boolean).join(' ')
if (f.preset === 'hourly') {
if (!Number.isInteger(f.minute) || f.minute < 0 || f.minute > 59 || !HOUR_STEPS.includes(f.everyHours)) return ''
return `${f.minute} ${f.everyHours === 1 ? '*' : `*/${f.everyHours}`} * * *`
}
const time = parseTime(f.time)
if (!time) return ''
const [h, m] = time
if (f.preset === 'daily') return `${m} ${h} * * *`
if (f.preset === 'weekly') return f.weekdays.length ? `${m} ${h} * * ${weekdayField(f.weekdays)}` : ''
if (!Number.isInteger(f.monthday) || f.monthday < 1 || f.monthday > 31) return ''
return `${m} ${h} ${f.monthday} * *`
}
/** 一段 cron 里的一个整数(不带步长、区间),超出范围或不是整数时返回 null */
function intField(text: string, lo: number, hi: number): number | null {
if (!/^\d{1,2}$/.test(text)) return null
const n = Number(text)
return n >= lo && n <= hi ? n : null
}
/** 星期几那一段(0–7 的数字、逗号列表、区间)→ 排好序的星期几;写了名字、步长之类的返回 null,交给自定义 */
function parseWeekdays(text: string): number[] | null {
const out = new Set<number>()
for (const part of text.split(',')) {
const range = /^(\d)-(\d)$/.exec(part)
if (range) {
const a = Number(range[1])
const b = Number(range[2])
if (a > b || b > 7) return null
for (let d = a; d <= b; d++) out.add(d === 7 ? 0 : d)
continue
}
const d = intField(part, 0, 7)
if (d == null) return null
out.add(d === 7 ? 0 : d)
}
return out.size ? [...out].sort((a, b) => a - b) : null
}
/**
* 已有的 cron 落回预设:编辑时照它选中「每周」、勾上周一。认不出的(写了月份、名字、步长、
* 「每隔几小时」不是 24 的约数)一律算自定义,原文照放,不改写别人写的 cron
*/
export function freqOf(cron: string): Freq {
const parts = cron.trim().split(/\s+/).filter(Boolean)
const base: Freq = { ...DEFAULT_FREQ, cron: parts.join(' ') }
const custom: Freq = { ...base, preset: 'custom' }
if (parts.length !== 5) return custom
const [mi, ho, dom, mon, dow] = parts
if (mon !== '*') return custom
const minute = intField(mi, 0, 59)
if (minute == null) return custom
const hour = intField(ho, 0, 23)
if (hour != null) {
const time = `${pad2(hour)}:${pad2(minute)}`
if (dom === '*' && dow === '*') return { ...base, preset: 'daily', time }
if (dom === '*') {
const weekdays = parseWeekdays(dow)
return weekdays ? { ...base, preset: 'weekly', time, weekdays } : custom
}
if (dow === '*') {
const monthday = intField(dom, 1, 31)
return monthday != null ? { ...base, preset: 'monthly', time, monthday } : custom
}
return custom
}
if (dom !== '*' || dow !== '*') return custom
if (ho === '*') return { ...base, preset: 'hourly', everyHours: 1, minute }
const step = /^\*\/(\d{1,2})$/.exec(ho)
if (step && HOUR_STEPS.includes(Number(step[1]))) return { ...base, preset: 'hourly', everyHours: Number(step[1]), minute }
return custom
}
// ---------------------------------------------------------------------------
// 时区与时间
// ---------------------------------------------------------------------------
/** 本机时区;浏览器说不出来时按 UTC */
export function browserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/** 常用时区,下拉里排在前面。城市名在 SCHEDULE_TEXT.timezoneLabel,偏移按当前日期另算(夏令时会变) */
export const COMMON_TIMEZONES: readonly string[] = [
'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Taipei', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Kolkata',
'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'America/New_York', 'America/Chicago',
'America/Los_Angeles', 'UTC',
]
/**
* 时区下拉的选项:本机时区、正在用的时区排最前(不在常用表里也列上),再接常用的,不重复。
* label 写「Asia/Shanghai(北京,UTC+08:00)」,本机的另标「本机」
*/
export function timezoneOptions(current: string, local: string = browserTimezone(), now: Date = new Date()):
{ tz: string; label: string }[] {
const seen = new Set<string>()
const out: { tz: string; label: string }[] = []
for (const tz of [local, current, ...COMMON_TIMEZONES]) {
if (!tz || seen.has(tz)) continue
seen.add(tz)
const notes = [T.timezoneLabel[tz], offsetLabel(tz, now), tz === local ? T.timezoneLocal : ''].filter(Boolean)
out.push({ tz, label: notes.length ? `${tz}(${notes.join(',')})` : tz })
}
return out
}
export interface ZonedParts {
year: number
month: number
day: number
hour: number
minute: number
/** 0 是周日 */
weekday: number
}
const formatters = new Map<string, Intl.DateTimeFormat | null>()
const WEEKDAY_EN: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
/** 某个时刻在某个时区里的年月日时分和星期。时区名浏览器不认时返回 null */
export function zonedParts(d: Date, tz: string): ZonedParts | null {
if (Number.isNaN(d.getTime())) return null
let f = formatters.get(tz)
if (f === undefined) {
try {
// 用 en-US 取各段的数字和英文星期缩写,再自己拼中文:各浏览器的 zh-CN 写法不完全一样
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric',
weekday: 'short', hourCycle: 'h23',
})
} catch {
f = null
}
formatters.set(tz, f)
}
if (!f) return null
const p: Record<string, string> = {}
for (const part of f.formatToParts(d)) p[part.type] = part.value
const parts = {
year: Number(p.year), month: Number(p.month), day: Number(p.day),
// 个别引擎在 h23 下仍把午夜写成 24
hour: Number(p.hour) % 24, minute: Number(p.minute), weekday: WEEKDAY_EN[p.weekday] ?? -1,
}
return Object.values(parts).every((v) => Number.isFinite(v) && v >= 0) ? parts : null
}
/** 这个时区此刻相对 UTC 的偏移:「UTC+08:00」「UTC-04:00」「UTC」 */
export function offsetLabel(tz: string, d: Date = new Date()): string {
const p = zonedParts(d, tz)
if (!p) return ''
const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute)
const minutes = Math.round((wall - Math.floor(d.getTime() / 60_000) * 60_000) / 60_000)
if (minutes === 0) return 'UTC'
const abs = Math.abs(minutes)
return `UTC${minutes > 0 ? '+' : '-'}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
}
/**
* 按时区写一个时刻。full:「2026-10-06 周一 09:00」;short:同一年省掉年份「10/6 周一 09:00」,
* 跨年写「2027/1/4 周一 09:00」。时区名浏览器不认时按本机时区写
*/
export function formatZoned(d: Date, tz: string, mode: 'full' | 'short' = 'short', now: Date = new Date()): string {
const p = zonedParts(d, tz) ?? zonedParts(d, browserTimezone())
if (!p) return '—'
const hm = `${pad2(p.hour)}:${pad2(p.minute)}`
const wd = T.weekdayShort[p.weekday]
if (mode === 'full') return `${p.year}-${pad2(p.month)}-${pad2(p.day)} ${wd} ${hm}`
const thisYear = (zonedParts(now, tz) ?? p).year
const md = `${p.month}/${p.day}`
return `${p.year === thisYear ? md : `${p.year}/${md}`} ${wd} ${hm}`
}
/**
* 距离一个将来时刻还有多久:「不到 1 分钟后」「12 分钟后」「3 小时后」「2 天后」。先取整再判档,59.6 分钟
* 写「1 小时后」而不是「60 分钟后」。已经过了的返回 null,由调用方照实说(调度可能没开、或者正在发起)
*/
export function formatUntil(target: Date, now: number): string | null {
const diff = target.getTime() - now
if (diff < 0) return null
if (diff < 60_000) return T.until.soon
const m = Math.round(diff / 60_000)
if (m < 60) return T.until.minutes(m)
const h = Math.round(diff / 3_600_000)
if (h < 24) return T.until.hours(h)
return T.until.days(Math.round(diff / 86_400_000))
}
/** 补发宽限的写法:「5 分钟」「1 小时」「1 天」,凑不成整数的按秒写 */
export function graceLabel(seconds: number): string {
if (seconds > 0 && seconds % 86_400 === 0) return T.span.day(seconds / 86_400)
if (seconds > 0 && seconds % 3600 === 0) return T.span.hour(seconds / 3600)
if (seconds > 0 && seconds % 60 === 0) return T.span.minute(seconds / 60)
return T.span.second(seconds)
}
/**
* 补发宽限的可选值。不给 0:调度 15 秒一拍,到点的触发总要晚上几秒,宽限为 0 等于每次都算错过、全部跳过
*/
export const GRACE_OPTIONS: readonly number[] = [300, 3600, 21_600, 86_400]
export const DEFAULT_GRACE_S = 3600
// ---------------------------------------------------------------------------
// 表单 ⇄ 请求体
// ---------------------------------------------------------------------------
export const WEBHOOK_FORMATS: readonly ScheduleWebhookFormat[] = ['generic', 'feishu', 'wecom', 'dingtalk']
export const SCHEDULE_EVENTS: readonly ScheduleEvent[] = ['finished', 'failed', 'awaiting_approval']
export interface WebhookForm {
on: boolean
format: ScheduleWebhookFormat
/** 新填的地址。已保存过地址、又没点「替换」时不用它 */
url: string
/** 已保存过地址时:true 表示换成上面新填的 */
replaceUrl: boolean
secret: string
/** 已设置过密钥时:keep 保留、replace 换成上面新填的、clear 清除。没设置过时只看 secret 填没填 */
secretMode: 'keep' | 'replace' | 'clear'
events: ScheduleEvent[]
}
export interface ScheduleForm {
name: string
workflowId: string
freq: Freq
timezone: string
runClass: ScheduleRunClass
/** 字段名 → 模板,留空的字段不交(按工作流的默认值) */
inputs: Record<string, string>
/** 只在新建时用:修改时启用、暂停走页头的开关,立刻生效 */
enabled: boolean
misfireGraceS: number
webhook: WebhookForm
}
/** 表单初值:新建时用默认值(时区取本机),修改时照读到的定时任务 */
export function formOf(s: Schedule | null, timezone: string = browserTimezone()): ScheduleForm {
if (!s) {
return {
name: '', workflowId: '', freq: { ...DEFAULT_FREQ }, timezone, runClass: 'formal', inputs: {}, enabled: true,
misfireGraceS: DEFAULT_GRACE_S,
webhook: { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] },
}
}
return {
name: s.name,
workflowId: s.workflow_id,
freq: freqOf(s.cron),
timezone: s.timezone,
runClass: s.run_class,
inputs: { ...(s.input_template ?? {}) },
enabled: s.enabled,
misfireGraceS: s.misfire_grace_s,
webhook: s.webhook
? { on: true, format: s.webhook.format, url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...s.webhook.events] }
: { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] },
}
}
/** 交上去的输入模板:只留填了的字段(只有空白的也算没填),留空的按工作流的默认值 */
export function templateOf(inputs: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(inputs).filter(([, v]) => typeof v === 'string' && v.trim() !== ''))
}
/** 事件按固定顺序排,勾选的先后不影响请求体,也不让「改过又改回」算成改动 */
const sortEvents = (events: ScheduleEvent[]) => SCHEDULE_EVENTS.filter((e) => events.includes(e))
export function createBody(f: ScheduleForm): ScheduleCreate {
const w = f.webhook
return {
name: f.name.trim(),
workflow_id: f.workflowId,
cron: cronOf(f.freq),
timezone: f.timezone,
run_class: f.runClass,
input_template: templateOf(f.inputs),
enabled: f.enabled,
overlap: 'skip',
misfire_grace_s: f.misfireGraceS,
webhook: w.on
? { format: w.format, url: w.url.trim(), ...(w.secret ? { secret: w.secret } : {}), events: sortEvents(w.events) }
: null,
}
}
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
const sortedEntries = (o: Record<string, string>) => Object.entries(o).sort(([a], [b]) => a.localeCompare(b))
/**
* 修改时交上去的字段(不含 version):拿表单和打开时的表单比,只带改了的,什么都没改返回 null。和表单初值比、
* 不和服务端的原文比:cron 落回预设再生成,写法可能和原文不同(0-6 变成 *),人没动它就不该算改了频率。
*
* Webhook 按约定:地址省略表示不改、空串表示删除;密钥省略表示不改、空串表示清除。要交 Webhook 时格式和事件
* 总是一起交上,免得服务端把没写的当成清空
*/
export function patchBody(f: ScheduleForm, initial: ScheduleForm, saved: Schedule): Omit<SchedulePatch, 'version'> | null {
const out: Omit<SchedulePatch, 'version'> = {}
if (f.name.trim() !== initial.name.trim()) out.name = f.name.trim()
if (f.workflowId !== initial.workflowId) out.workflow_id = f.workflowId
if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq)
if (f.timezone !== initial.timezone) out.timezone = f.timezone
if (f.runClass !== initial.runClass) out.run_class = f.runClass
if (!sameJson(sortedEntries(templateOf(f.inputs)), sortedEntries(templateOf(initial.inputs)))) {
out.input_template = templateOf(f.inputs)
}
if (f.misfireGraceS !== initial.misfireGraceS) out.misfire_grace_s = f.misfireGraceS
const webhook = webhookPatch(f.webhook, initial.webhook, saved)
if (webhook) out.webhook = webhook
return Object.keys(out).length ? out : null
}
function webhookPatch(w: WebhookForm, before: WebhookForm, saved: Schedule): ScheduleWebhookIn | null {
const events = sortEvents(w.events)
if (!saved.webhook) {
// 原来没有:打开了才交,地址必填
if (!w.on) return null
return { format: w.format, url: w.url.trim(), ...(w.secret ? { secret: w.secret } : {}), events }
}
if (!w.on) return { format: saved.webhook.format, url: '', events: sortEvents(saved.webhook.events) }
const out: ScheduleWebhookIn = { format: w.format, events }
if (w.replaceUrl && w.url.trim()) out.url = w.url.trim()
if (saved.webhook.has_secret) {
if (w.secretMode === 'clear') out.secret = ''
else if (w.secretMode === 'replace' && w.secret) out.secret = w.secret
} else if (w.secret) {
out.secret = w.secret
}
const changed = out.url != null || out.secret != null || w.format !== before.format
|| !sameJson(events, sortEvents(before.events))
return changed ? out : null
}
// ---------------------------------------------------------------------------
// 表单的问题
// ---------------------------------------------------------------------------
export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'url' | 'secret' | 'events'
/** 表单哪几项还不能保存,值是写在那一项下面的话。saved 是修改中的定时任务(新建时为 null) */
export function formProblems(f: ScheduleForm, saved: Schedule | null): Partial<Record<FormField, string>> {
const out: Partial<Record<FormField, string>> = {}
if (!f.name.trim()) out.name = T.problem.name
if (!f.workflowId) out.workflow = T.problem.workflow
const freq = freqProblem(f.freq)
if (freq) out.freq = freq
if (!f.timezone) out.timezone = T.problem.timezone
const w = f.webhook
if (w.on) {
const needUrl = !saved?.webhook || w.replaceUrl
if (needUrl && !w.url.trim()) out.url = T.problem.url
else if (needUrl && !isHttpUrl(w.url)) out.url = T.problem.urlInvalid
if (saved?.webhook?.has_secret && w.secretMode === 'replace' && !w.secret) out.secret = T.problem.secret
if (!w.events.length) out.events = T.problem.events
}
return out
}
/** 频率填得哪里不完整;完整时返回 null(cron 合不合法由服务端的预览判) */
export function freqProblem(f: Freq): string | null {
if (f.preset === 'custom') {
const n = f.cron.trim().split(/\s+/).filter(Boolean).length
if (!n) return T.problem.cronEmpty
return n === 5 ? null : T.problem.cronFields
}
if (f.preset === 'hourly') return cronOf(f) ? null : T.problem.minute
if (!parseTime(f.time)) return T.problem.time
if (f.preset === 'weekly' && !f.weekdays.length) return T.problem.weekdays
return null
}
/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook、服务访问地址都用它) */
export function isHttpUrl(text: string): boolean {
return /^https?:\/\/\S+$/i.test(text.trim())
}
+158
View File
@@ -3167,3 +3167,161 @@ export interface WhatIfResult {
unsupported: WhatIfUnsupported[]
affected_cards: WhatIfAffectedCard[]
}
// ---------------------------------------------------------------------------
// 定时任务(/api/schedules,页面在 pages/schedules)。前后端按同一份接口约定,字段名和形状不各自发挥;时间一律是
// ISO 字符串(UTC),按定时任务自己的时区显示
// ---------------------------------------------------------------------------
/**
* 运行列表里的定时来源:发起它的定时任务,手动发起的是 null,老服务端不给。写在这里而不是上面 Run 的定义里:
* 同名接口会合并,定时任务的类型集中在文件末尾这一段,不和别的工作包改同一处
*/
export interface Run {
schedule_id?: string | null
}
export type ScheduleRunClass = 'formal' | 'exploratory'
/** Webhook 消息的格式:通用 JSON、飞书 / 企业微信 / 钉钉的群机器人 */
export type ScheduleWebhookFormat = 'generic' | 'feishu' | 'wecom' | 'dingtalk'
/** 订阅的事件:运行结束、运行失败、停在人工审批上(只在真有待审批时发) */
export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval'
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发 */
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire'
/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */
export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start'
/** 读出来的 Webhook 设置。完整地址服务端加密保存、不再返回,只露主机和末 4 位 */
export interface ScheduleWebhook {
format: ScheduleWebhookFormat
/** 如 open.feishu.cn/…ab12 */
url_hint: string
has_secret: boolean
events: ScheduleEvent[]
}
/** 一次触发发出的一条通知 */
export interface ScheduleFireNotice {
event: ScheduleEvent
status: 'ok' | 'failed'
at: string
}
/** 一次触发(触发历史的一行) */
export interface ScheduleFire {
id: string
/** 计划触发的时间;手动运行是按下的那一刻 */
fire_at: string
kind: ScheduleFireKind
status: ScheduleFireStatus
run_id: string | null
/** 发起的那次运行现在的状态(同 Run.status) */
run_status: RunStatus | null
/** 跳过、发起失败的原因(中文) */
reason: string | null
notified: ScheduleFireNotice[]
created_at: string
}
export interface Schedule {
id: string
name: string
workflow_id: string
workflow_name: string
/** 五段 cron:分 时 日 月 星期 */
cron: string
/** IANA 时区名,如 Asia/Shanghai */
timezone: string
run_class: ScheduleRunClass
/** 字段名 → 模板字符串,变量({{ schedule.yesterday }} 这类)按触发时间计算 */
input_template: Record<string, string>
enabled: boolean
/** 上一次还没结束时怎么办:目前只有跳过 */
overlap: 'skip'
/** 错过触发时间后多少秒内恢复还补发一次 */
misfire_grace_s: number
webhook: ScheduleWebhook | null
/** 暂停中、或者往后不会再触发时为 null */
next_run_at: string | null
last_fire: ScheduleFire | null
/** 频率的中文说法,如「每周一 09:00(Asia/Shanghai)」 */
description_text: string
created_by: string | null
created_at: string
updated_at: string
/** 乐观锁:修改时带上读到的版本,别人在这期间改过时回 409 */
version: number
}
/** 新建、修改时交上去的 Webhook。地址、密钥的明文只在写的时候给 */
export interface ScheduleWebhookIn {
format: ScheduleWebhookFormat
/** 修改时:省略表示不改,空串表示删除整个 Webhook */
url?: string
/** 修改时:省略表示不改,空串表示清除 */
secret?: string
events: ScheduleEvent[]
}
/** POST /api/schedules 的请求体 */
export interface ScheduleCreate {
name: string
workflow_id: string
cron: string
timezone: string
run_class: ScheduleRunClass
input_template: Record<string, string>
enabled: boolean
overlap: 'skip'
misfire_grace_s: number
webhook: ScheduleWebhookIn | null
}
/** PATCH /api/schedules/{id} 的请求体:只带改了的字段,外加读到的版本 */
export type SchedulePatch = Partial<Omit<ScheduleCreate, 'webhook'>> & {
webhook?: ScheduleWebhookIn
version: number
}
export interface SchedulePreviewRequest {
cron: string
timezone: string
input_template?: Record<string, string>
workflow_id?: string
n?: number
}
/** 预览:频率的中文说法、接下来几次触发时间、按下一次触发时间渲染出的输入。非法时 422 {detail, field} */
export interface SchedulePreview {
description_text: string
next: string[]
inputs: Record<string, string>
}
/** 输入模板里能用的变量 */
export interface ScheduleVariable {
/** 如 schedule.yesterday */
name: string
/** 如「昨天(YYYY-MM-DD)」 */
label: string
example: string
}
/** POST /api/schedules/{id}/run:这次手动触发和它发起的运行(没发起时为 null) */
export interface ScheduleRunResult {
fire: ScheduleFire
run_id: string | null
}
/** GET /api/schedules/{id}/fires 的一页,新的在前;next_before 是下一页的游标,没有更多时为 null */
export interface ScheduleFirePage {
items: ScheduleFire[]
next_before: string | null
}
/** POST /api/schedules/{id}/webhook/test:用已保存的设置发一条测试消息 */
export interface ScheduleWebhookTest {
ok: boolean
http_status: number | null
message: string
}
+6
View File
@@ -54,6 +54,12 @@
"text": "报告按 [[i:字段名]]",
"rule": "半角标点",
"reason": "[[i:字段名]] 是报告里引用运行输入的标记写法,冒号是语法的一部分,提示用户照原样写"
},
{
"file": "frontend/src/lib/terms.ts",
"text": "钉钉",
"rule": "钉",
"reason": "「钉钉」是 Webhook 通知的产品名(定时任务的消息格式:通用 / 飞书 / 企业微信 / 钉钉),不是「钉住」的意思"
}
]
}