mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
定时任务前端底座:接口约定的类型、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:
@@ -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
@@ -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,别混用 */
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
"text": "报告按 [[i:字段名]]",
|
||||
"rule": "半角标点",
|
||||
"reason": "[[i:字段名]] 是报告里引用运行输入的标记写法,冒号是语法的一部分,提示用户照原样写"
|
||||
},
|
||||
{
|
||||
"file": "frontend/src/lib/terms.ts",
|
||||
"text": "钉钉",
|
||||
"rule": "钉",
|
||||
"reason": "「钉钉」是 Webhook 通知的产品名(定时任务的消息格式:通用 / 飞书 / 企业微信 / 钉钉),不是「钉住」的意思"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user