diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index cb733715..d6af2afd 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -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(at(id), opts), + create: (body: ScheduleCreate) => post('/schedules', body), + /** 只带改了的字段和读到的版本;Webhook 的地址省略表示不改、空串表示删除 */ + update: (id: string, body: SchedulePatch) => patch(at(id), body), + remove: (id: string) => del(at(id)), + /** + * 接下来几次触发时间和渲染出的输入,只算不存。signal 用来丢掉过时的请求:频率还在改、又发了新的一次时, + * 上一次的答复不再要(取消照旧抛 AbortError) + */ + preview: (body: SchedulePreviewRequest, opts?: { signal?: AbortSignal }) => + request('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }), + /** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */ + variables: (opts?: RequestOptions) => get('/schedules/variables', opts), + /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ + run: (id: string) => post(`${at(id)}/run`), + /** 触发历史,新的在前。before 是上一页给的 next_before */ + fires: (id: string, page?: { limit?: number; before?: string | null }, opts?: RequestOptions) => + get(`${at(id)}/fires${qs(page)}`, opts), + /** 用已保存的 Webhook 设置发一条测试消息(不用页面上还没保存的) */ + testWebhook: (id: string) => post(`${at(id)}/webhook/test`), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index da35a65f..537d183a 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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, + fireKindHint: { + scheduled: '到点按计划触发', + manual: '手动「立即运行」', + misfire: '错过触发时间后,在补发宽限内恢复时补发的一次', + } as Record, + fireStatus: { + started: '已发起', + skipped_overlap: '上次未结束已跳过', + skipped_misfire: '错过已跳过', + failed_to_start: '发起失败', + } as Record, + fireStatusHint: { + started: '已发起运行', + skipped_overlap: '上一次运行还没有结束,按重叠策略跳过了这一次', + skipped_misfire: '服务停机等原因错过了触发时间,且超出补发宽限,这一次没有发起', + failed_to_start: '到点发起时被拒绝,原因见本条记录', + } as Record, + event: { finished: '运行结束', failed: '运行失败', awaiting_approval: '等待审批' } as Record, + 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, + + // ---- 表单:预览 + 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, + 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, + url: '地址', + urlSaved: '已保存的地址', + urlSavedHint: '完整地址已加密保存,只显示主机和末 4 位', + urlReplace: '替换地址', + urlKeep: '保留原地址', + webhookRemove: '删除 Webhook', + webhookRemoving: '保存后将删除这个 Webhook', + webhookUndoRemove: '撤销删除', + secret: '签名密钥', + secretHint: { + generic: '可选。填写后每条消息带签名,接收端可据此校验来源', + feishu: '可选。机器人开启了签名校验时填写', + wecom: '企业微信群机器人不使用签名,留空即可', + dingtalk: '可选。机器人开启了加签时填写', + } as Record, + 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,别混用 */ diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts new file mode 100644 index 00000000..0feedcbd --- /dev/null +++ b/frontend/src/pages/schedules/model.ts @@ -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() + 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() + 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() +const WEEKDAY_EN: Record = { 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 = {} + 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 + /** 只在新建时用:修改时启用、暂停走页头的开关,立刻生效 */ + 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): Record { + 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) => 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 | null { + const out: Omit = {} + 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> { + const out: Partial> = {} + 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()) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 39866717..fcc0281b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 + 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 + enabled: boolean + overlap: 'skip' + misfire_grace_s: number + webhook: ScheduleWebhookIn | null +} + +/** PATCH /api/schedules/{id} 的请求体:只带改了的字段,外加读到的版本 */ +export type SchedulePatch = Partial> & { + webhook?: ScheduleWebhookIn + version: number +} + +export interface SchedulePreviewRequest { + cron: string + timezone: string + input_template?: Record + workflow_id?: string + n?: number +} + +/** 预览:频率的中文说法、接下来几次触发时间、按下一次触发时间渲染出的输入。非法时 422 {detail, field} */ +export interface SchedulePreview { + description_text: string + next: string[] + inputs: Record +} + +/** 输入模板里能用的变量 */ +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 +} diff --git a/scripts/copy-allow.json b/scripts/copy-allow.json index 4c31bf7a..b871e762 100644 --- a/scripts/copy-allow.json +++ b/scripts/copy-allow.json @@ -54,6 +54,12 @@ "text": "报告按 [[i:字段名]]", "rule": "半角标点", "reason": "[[i:字段名]] 是报告里引用运行输入的标记写法,冒号是语法的一部分,提示用户照原样写" + }, + { + "file": "frontend/src/lib/terms.ts", + "text": "钉钉", + "rule": "钉", + "reason": "「钉钉」是 Webhook 通知的产品名(定时任务的消息格式:通用 / 飞书 / 企业微信 / 钉钉),不是「钉住」的意思" } ] }