mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:51:57 +00:00
合入期 2 定时任务(前端):定时任务列表、创建编辑(频率预设、实时预览、输入模板变量、Webhook 设置与测试)、触发历史,运行列表标出定时来源
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`),
|
||||
}
|
||||
|
||||
+320
-3
@@ -2732,12 +2732,329 @@ 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):服务端那句是写给表单保存的,开关用这句,原话放进详情 */
|
||||
toggleConflict: (name: string) => `「${name}」已在别处被修改,已重新载入最新状态,请确认后再切换`,
|
||||
|
||||
// ---- 立即运行的结果
|
||||
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>,
|
||||
/**
|
||||
* 错过、没有补发的(服务端记 kind=misfire、status=skipped_misfire):类型按「定时」写。没有补发的原因有两种(超出
|
||||
* 补发宽限;宽限内恢复时积压的触发不补,只发起最近一次),这里不替服务端认定是哪一种,本条记了原因时指向它
|
||||
*/
|
||||
fireKindMissedHint: (hasReason: boolean) => `按计划应在此时触发,错过后没有补发${hasReason ? ',原因见本条记录' : ''}`,
|
||||
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: '必填且没有默认值:留空时到点会发起失败',
|
||||
/** 新建、或者这次改了工作流、运行类别、输入:服务端保存时就会拒 */
|
||||
requiredFill: '必填且没有默认值,请填写后再保存',
|
||||
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: '工作流中已没有这个字段',
|
||||
/** 画布上有、正式运行用的已发布版本里没有 */
|
||||
staleNotInVersion: (v: number) => `已发布的 v${v} 中没有这个字段`,
|
||||
/** 接在上面两句后面:交上去会被拒 */
|
||||
staleRemove: '保存前请移除',
|
||||
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: '测试消息已送达',
|
||||
testFailed: '测试消息发送失败',
|
||||
/** 结局后面接状态码和说明(两样都可能没有) */
|
||||
testResult: (head: string, status: number | null, detail: string) =>
|
||||
`${head}${status != null ? `(HTTP ${status})` : ''}${detail ? `:${detail}` : ''}`,
|
||||
|
||||
// ---- 通知里的运行链接(设置 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: '至少选择一个事件',
|
||||
notPublished: '这个工作流还没有发布版本,正式运行需要已发布的版本。请先发布,或改用探索运行',
|
||||
inputsMissing: (names: string) => `必填输入「${names}」还没有填写`,
|
||||
inputsUnknown: (names: string) => `输入「${names}」对不上工作流的输入字段,请移除`,
|
||||
},
|
||||
/** 「还需处理」里各项的叫法 */
|
||||
problemField: {
|
||||
name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', runClass: '运行类别', inputs: '输入', url: 'Webhook 地址',
|
||||
secret: '签名密钥', events: '通知事件',
|
||||
} as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | '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}」发起,点击查看` : '由定时任务发起,点击查看'),
|
||||
/** 读屏念的来源(整行是打开运行的按钮,标记本身点不到,至少在名字里听到) */
|
||||
runTagSr: (name?: string | null) => (name ? `由定时任务「${name}」发起` : '由定时任务发起'),
|
||||
}
|
||||
|
||||
/** 运行对比:记录页里两次运行的对照。配方前后对照另有 COMPARE_TEXT,别混用 */
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
headlineMs, idTail, isLiveRun, runShape, runTiming, timingTitle, type ParsedQuery,
|
||||
} from './model'
|
||||
import { ClassChip, LiveElapsed, TierChip } from './parts'
|
||||
import { ScheduleTag, useScheduleNames } from '../schedules/parts'
|
||||
import type { ApprovalQueue, RunList, StatusCounts } from './useRunsData'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -191,6 +192,8 @@ export function RunRows({
|
||||
}) {
|
||||
const changed = useChangedRows(list.rows)
|
||||
const now = useTicker(60_000)
|
||||
// 定时任务发起的运行在名字旁标「定时」,悬停写是哪个定时任务:名字按需取一次
|
||||
const scheduleNames = useScheduleNames(list.rows.map((r) => r.schedule_id))
|
||||
|
||||
if (list.state === 'loading') {
|
||||
return <div className="p-3"><Skeleton rows={6} height={46} gap={8} /></div>
|
||||
@@ -241,6 +244,7 @@ export function RunRows({
|
||||
flash={changed.get(run.id)}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
scheduleName={run.schedule_id ? scheduleNames.get(run.schedule_id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -295,10 +299,12 @@ function useChangedRows(rows: Run[]): Map<string, number> {
|
||||
return changed
|
||||
}
|
||||
|
||||
function RunRow({ run, code, approval, selected, dup, flash, now, onOpen }: {
|
||||
function RunRow({ run, code, approval, selected, dup, flash, now, onOpen, scheduleName }: {
|
||||
run: Run; code: StatusCode; approval?: Approval; selected: boolean; dup: boolean
|
||||
/** 状态刚变过:第几次变化(换 key 重播闪光) */
|
||||
flash?: number; now: number; onOpen: (id: string) => void
|
||||
/** 发起它的定时任务的名字(不是定时任务发起的、或名字还没取到时没有) */
|
||||
scheduleName?: string
|
||||
}) {
|
||||
const meta = statusMeta(code)
|
||||
const timing = runTiming(run)
|
||||
@@ -341,6 +347,7 @@ function RunRow({ run, code, approval, selected, dup, flash, now, onOpen }: {
|
||||
{idTail(run.workflow_id)}
|
||||
</span>
|
||||
)}
|
||||
{run.schedule_id && <ScheduleTag scheduleId={run.schedule_id} name={scheduleName} />}
|
||||
<span className="flex-1" />
|
||||
<ClassChip runClass={run.run_class} version={run.version} />
|
||||
<TierChip tier={tier} />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight, History, RotateCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui'
|
||||
import { formatDateTime, parseServerTime } from '../../lib/format'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import type { Schedule, ScheduleFire } from '../../types'
|
||||
import { FireKindChip, FireStatusChip, ZonedTime } from './parts'
|
||||
|
||||
const PAGE = 20
|
||||
|
||||
/**
|
||||
* 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败
|
||||
* 写原因;发起了的链到那次运行、写运行现在的状态;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取
|
||||
*/
|
||||
export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) {
|
||||
const [items, setItems] = useState<ScheduleFire[] | null>(null)
|
||||
const [next, setNext] = useState<string | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const now = useTicker(60_000)
|
||||
// 从头重取时作废还在路上的「加载更多」:两份答复交错接上会重复、会漏
|
||||
const seq = useRef(0)
|
||||
const id = schedule.id
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const n = ++seq.current
|
||||
try {
|
||||
const page = await api.schedules.fires(id, { limit: PAGE })
|
||||
if (n !== seq.current) return
|
||||
setItems(page.items ?? [])
|
||||
setNext(page.next_before ?? null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (n === seq.current) setError(e)
|
||||
}
|
||||
}, [id])
|
||||
useEffect(() => { void load() }, [load, refreshKey])
|
||||
|
||||
const more = async () => {
|
||||
if (!next || loadingMore) return
|
||||
const n = seq.current
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const page = await api.schedules.fires(id, { limit: PAGE, before: next })
|
||||
if (n !== seq.current) return
|
||||
setItems((cur) => {
|
||||
const seen = new Set((cur ?? []).map((f) => f.id))
|
||||
return [...(cur ?? []), ...(page.items ?? []).filter((f) => !seen.has(f.id))]
|
||||
})
|
||||
setNext(page.next_before ?? null)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
let body
|
||||
if (items === null) {
|
||||
body = error ? <ErrorState error={error} onRetry={() => void load()} compact /> : <Skeleton rows={4} height={44} gap={8} />
|
||||
} else if (!items.length) {
|
||||
body = (
|
||||
<div data-fire-empty="">
|
||||
<EmptyState icon={<History size={22} />} title={T.historyEmpty} body={T.historyEmptyHint} offline={false} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<div data-fire-list="">
|
||||
<ul className="overflow-hidden rounded-lg border bg-panel">
|
||||
{items.map((f) => <FireRow key={f.id} fire={f} tz={schedule.timezone} now={now} />)}
|
||||
</ul>
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-3 text-2xs text-faint">
|
||||
<span className="tnum">{T.shown(items.length)}</span>
|
||||
{next ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void more()} disabled={loadingMore} data-fire-more="">
|
||||
{loadingMore && <Spinner size={11} />} {T.loadMore}
|
||||
</button>
|
||||
) : <span>· {T.allLoaded}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-4xl p-4" aria-labelledby="schedule-history-title">
|
||||
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="schedule-history-title" className="text-sm font-semibold">{T.history}</h2>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-faint">{T.historyHint}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void load()} data-fire-refresh="">
|
||||
<RotateCw size={11} aria-hidden /> {T.historyRefresh}
|
||||
</button>
|
||||
</div>
|
||||
{body}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) {
|
||||
const at = parseServerTime(f.fire_at)
|
||||
return (
|
||||
<li className="border-b px-3 py-2.5 last:border-b-0" data-fire-id={f.id} data-fire-status={f.status} data-fire-kind={f.kind}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="sr-only">{f.kind === 'manual' ? T.fireAtManual : T.fireAt}</span>
|
||||
{at ? <ZonedTime date={at} tz={tz} now={now} className="text-xs text-fg" /> : <span className="text-xs text-faint">—</span>}
|
||||
<FireKindChip fire={f} />
|
||||
<FireStatusChip fire={f} />
|
||||
<span className="flex-1" />
|
||||
{f.run_id ? (
|
||||
<Link to={`/runs/${encodeURIComponent(f.run_id)}`} className="inline-flex shrink-0 items-center gap-1.5 text-2xs text-dim hover:text-fg hover:underline">
|
||||
{f.run_status && <StatusPill status={f.run_status} plain />}
|
||||
{T.openRun}
|
||||
<ChevronRight size={11} aria-hidden />
|
||||
</Link>
|
||||
) : <span className="shrink-0 text-2xs text-faint">{T.noRun}</span>}
|
||||
</div>
|
||||
{f.reason && (
|
||||
<div className="mt-1 text-2xs leading-relaxed text-dim">
|
||||
<span className="text-faint">{T.reason}:</span>{f.reason}
|
||||
</div>
|
||||
)}
|
||||
{f.notified?.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-2xs">
|
||||
<span className="text-faint">{T.notified}</span>
|
||||
{f.notified.map((n, i) => (
|
||||
<span key={`${n.event}-${i}`} title={formatDateTime(n.at)}
|
||||
style={{ color: n.status === 'ok' ? 'var(--text-dim)' : 'var(--st-failed)' }} data-fire-notice={n.status}>
|
||||
{T.event[n.event] ?? n.event} {T.noticeStatus[n.status] ?? n.status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeft, CalendarClock, Play, Trash2 } from 'lucide-react'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import { EmptyState, ErrorState, IconButton, PageHeader, Skeleton, Spinner, Tabs, toast, useTicker } from '../../components/ui'
|
||||
import { leavePass } from '../../lib/leave'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import { useOnReconnect } from '../../store/catalog'
|
||||
import type { Schedule } from '../../types'
|
||||
import { FireHistory } from './FireHistory'
|
||||
import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts'
|
||||
import { ScheduleEditor } from './ScheduleEditor'
|
||||
|
||||
const BACK = (
|
||||
<Link to="/schedules" className="btn btn-sm btn-ghost" aria-label={T.back} title={T.back} data-schedule-back="">
|
||||
<ArrowLeft size={12} aria-hidden /> <span className="hidden md:inline">{T.back}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
/** 新建定时任务(/schedules/new)。创建成功进它的详情页,替换掉这一条历史:后退不回到一张空表单 */
|
||||
export function ScheduleCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-schedules-page="new">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={T.newTitle} subtitle={T.hint} actions={BACK} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ScheduleEditor
|
||||
schedule={null}
|
||||
onSaved={(s) => {
|
||||
rememberScheduleNames([s])
|
||||
toast.ok(T.created(s.name))
|
||||
// 表单刚保存完、离开守卫还没来得及撤:这一跳带通行证
|
||||
navigate(`/schedules/${encodeURIComponent(s.id)}`, leavePass({ replace: true }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TabKey = 'settings' | 'history'
|
||||
|
||||
/**
|
||||
* 一个定时任务的详情(/schedules/:id):页头是名字、频率和启用开关、立即运行、删除;下面两个标签,设置(表单)
|
||||
* 和触发历史(?tab=history)。切到触发历史时表单不卸载,改到一半的内容还在。
|
||||
*
|
||||
* 两份状态分开记:schedule 是页头和摘要显示的、随时可以刷新的最新值;version 是表单保存时带的版本,只跟着自己的
|
||||
* 写操作(开关、保存)和明确的「重新载入」走。别人在这期间改过时表单保存就该 409,不能因为页头顺手刷新过就悄悄覆盖。
|
||||
* 唯一的例外是表单第一次挂上的那一刻:表单基于的就是那时的 schedule,版本对齐到它
|
||||
*/
|
||||
export function ScheduleDetailPage({ id }: { id: string }) {
|
||||
const navigate = useNavigate()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const tab: TabKey = params.get('tab') === 'history' ? 'history' : 'settings'
|
||||
const [schedule, setSchedule] = useState<Schedule | null>(null)
|
||||
const [version, setVersion] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [resetKey, setResetKey] = useState(0)
|
||||
const [historyKey, setHistoryKey] = useState(0)
|
||||
// 两个标签都是第一次打开时才挂上,之后切走也不卸载:直接打开触发历史时不必先把表单和预览请求一遍
|
||||
const [historySeen, setHistorySeen] = useState(tab === 'history')
|
||||
const [settingsSeen, setSettingsSeen] = useState(tab === 'settings')
|
||||
const [busy, setBusy] = useState<null | 'toggle' | 'run' | 'delete'>(null)
|
||||
const now = useTicker(30_000)
|
||||
const loaded = useRef(false)
|
||||
/**
|
||||
* 读的序号:后发的读胜过先发的读;开关、保存成功时也加一,作废还在路上的读。那次读可能在写之前读到旧值、
|
||||
* 在写的答复之后才回来,落地会把页头的开关、名字盖回旧的(详情页没有定时刷新,错的会一直挂着)
|
||||
*/
|
||||
const seq = useRef(0)
|
||||
const wrote = () => { seq.current++ }
|
||||
|
||||
/** 读最新的定时任务,读到了返回 true。adopt:表单保存时也改用它的版本(首次打开、重新载入) */
|
||||
const load = useCallback(async (adopt: boolean): Promise<boolean> => {
|
||||
const n = ++seq.current
|
||||
try {
|
||||
const s = await api.schedules.get(id)
|
||||
if (n !== seq.current) return false
|
||||
rememberScheduleNames([s])
|
||||
setSchedule(s)
|
||||
setError(null)
|
||||
if (adopt) setVersion(s.version)
|
||||
loaded.current = true
|
||||
return true
|
||||
} catch (e) {
|
||||
if (n !== seq.current) return false
|
||||
// 读到过之后刷新失败:页头照旧显示手上的,只在首次打开时整页报错
|
||||
if (!loaded.current || (e instanceof ApiError && e.status === 404)) setError(e)
|
||||
else if (adopt) toast.error(e)
|
||||
return false
|
||||
}
|
||||
}, [id])
|
||||
useEffect(() => { void load(true) }, [load])
|
||||
/** 版本不符之后的「重新载入」:表单换成服务端现在的设置(换 key 重新挂载) */
|
||||
const reload = async () => {
|
||||
if (await load(true)) setResetKey((k) => k + 1)
|
||||
}
|
||||
useOnReconnect(() => { void load(false) })
|
||||
useEffect(() => {
|
||||
if (tab === 'history') setHistorySeen(true)
|
||||
else setSettingsSeen(true)
|
||||
}, [tab])
|
||||
// 表单第一次挂上时版本对齐到此刻的 schedule(渲染中改状态,React 当场重渲染,表单挂上时两者已经一致)。
|
||||
// 直接打开触发历史时表单要等切过来才挂,这期间页头可能已经刷新过(立即运行之后、重连、开关冲突之后),
|
||||
// 表单显示的是刷新后的内容,还拿首次打开时的版本保存就会撞上并不存在的 409
|
||||
const [formBased, setFormBased] = useState(false)
|
||||
if (settingsSeen && schedule && !formBased) {
|
||||
setFormBased(true)
|
||||
if (version !== schedule.version) setVersion(schedule.version)
|
||||
}
|
||||
|
||||
const pickTab = (k: string) => setParams((p) => {
|
||||
const next = new URLSearchParams(p)
|
||||
if (k === 'history') next.set('tab', 'history')
|
||||
else next.delete('tab')
|
||||
return next
|
||||
}, { replace: true })
|
||||
|
||||
const act = async (what: 'toggle' | 'run' | 'delete', fn: (s: Schedule) => Promise<void>) => {
|
||||
if (!schedule || busy) return
|
||||
setBusy(what)
|
||||
try {
|
||||
await fn(schedule)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
const toggle = (enabled: boolean) => act('toggle', async (s) => {
|
||||
const out = await setEnabled(s, enabled)
|
||||
if (out === 'conflict') await load(false)
|
||||
else if (out) {
|
||||
wrote()
|
||||
setSchedule(out)
|
||||
// 表单的版本只在开关前后没有别人插进来时跟着走:页头是在别人改过之后刷新出来的,表单还是旧的那份,
|
||||
// 照旧带旧版本,保存时由服务端说清
|
||||
setVersion((v) => (v === s.version ? out.version : v))
|
||||
}
|
||||
})
|
||||
const run = () => act('run', async (s) => {
|
||||
const out = await runNow(s, navigate)
|
||||
if (!out) return
|
||||
setSchedule((cur) => (cur ? { ...cur, last_fire: out.fire } : cur))
|
||||
setHistoryKey((k) => k + 1)
|
||||
void load(false)
|
||||
})
|
||||
const remove = () => act('delete', async (s) => {
|
||||
if (await removeSchedule(s)) navigate('/schedules', leavePass({ replace: true }))
|
||||
})
|
||||
|
||||
const missing = error instanceof ApiError && error.status === 404 && !isUnsupported(error)
|
||||
let content
|
||||
if (missing) {
|
||||
content = (
|
||||
<div data-schedule-missing="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.notFound} body={T.notFoundHint} offline={false}
|
||||
action={<Link to="/schedules" className="btn btn-sm">{T.back}</Link>} />
|
||||
</div>
|
||||
)
|
||||
} else if (isUnsupported(error)) {
|
||||
content = <EmptyState icon={<CalendarClock size={22} />} title={T.unsupported} body={T.unsupportedHint} offline={false} />
|
||||
} else if (!schedule) {
|
||||
content = error
|
||||
? <ErrorState error={error} onRetry={() => void load(true)} />
|
||||
: <div className="mx-auto max-w-5xl p-4"><Skeleton rows={6} height={40} gap={10} /></div>
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{schedule && (
|
||||
<>
|
||||
<EnableSwitch checked={schedule.enabled} busy={busy === 'toggle'} label={T.enableLabel(schedule.name)} onChange={(v) => void toggle(v)} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => void run()} disabled={!!busy}
|
||||
aria-label={T.runNowLabel(schedule.name)} title={T.runNowLabel(schedule.name)} data-schedule-run="">
|
||||
{busy === 'run' ? <Spinner size={11} /> : <Play size={11} aria-hidden />}
|
||||
<span className="hidden sm:inline">{T.runNow}</span>
|
||||
</button>
|
||||
<IconButton label={T.deleteLabel(schedule.name)} onClick={() => void remove()} disabled={!!busy} data-schedule-delete=""
|
||||
className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={<Trash2 size={12} />} />
|
||||
</>
|
||||
)}
|
||||
{BACK}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-schedules-page="detail">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={schedule?.name ?? T.title} subtitle={schedule?.description_text}
|
||||
actions={actions} />
|
||||
{content ?? (schedule && (
|
||||
<>
|
||||
<div className="flex shrink-0 flex-wrap gap-x-5 gap-y-1 border-b bg-panel px-4 py-2 text-2xs" data-schedule-summary="">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0 text-faint">{T.nextRun}</span>
|
||||
<NextRun schedule={schedule} now={now} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0 text-faint">{T.lastFire}</span>
|
||||
<LastFire fire={schedule.last_fire} tz={schedule.timezone} now={now} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs tabs={[{ key: 'settings', label: T.tabs.settings }, { key: 'history', label: T.tabs.history }]}
|
||||
active={tab} onChange={pickTab} label={T.title} idPrefix="schedule" />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{settingsSeen && (
|
||||
<div role="tabpanel" id="schedule-panel-settings" aria-labelledby="schedule-tab-settings" hidden={tab !== 'settings'}>
|
||||
<ScheduleEditor
|
||||
key={resetKey}
|
||||
schedule={schedule}
|
||||
version={version}
|
||||
onSaved={(s) => {
|
||||
wrote()
|
||||
rememberScheduleNames([s])
|
||||
setSchedule(s)
|
||||
setVersion(s.version)
|
||||
}}
|
||||
onReload={() => void reload()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{historySeen && (
|
||||
<div role="tabpanel" id="schedule-panel-history" aria-labelledby="schedule-tab-history" hidden={tab !== 'history'}>
|
||||
<FireHistory schedule={schedule} refreshKey={historyKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Check } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import { Field, Notice, Skeleton, Spinner, confirmDialog, toast, useRadioGroup } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { parseServerTime } from '../../lib/format'
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import { useCatalog, useCatalogListState } from '../../store/catalog'
|
||||
import type { Schedule, SchedulePreview, ScheduleRunClass, ScheduleVariable, Workflow } from '../../types'
|
||||
import {
|
||||
FREQ_PRESETS, GRACE_OPTIONS, HOUR_STEPS, WEEKDAY_ORDER, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem,
|
||||
graceLabel, graphFields, patchBody, rebaseForm, serverFieldValue, templateOf, timezoneOptions, type Freq, type FormField,
|
||||
type FreqPreset, type InputField, type ScheduleForm,
|
||||
} from './model'
|
||||
import { isUnsupported } from './parts'
|
||||
import { WebhookFields } from './WebhookFields'
|
||||
import { WorkflowPicker } from './WorkflowPicker'
|
||||
|
||||
/**
|
||||
* 新建、修改定时任务的表单。宽屏左边是表单、右边是接下来 5 次触发的预览(跟着滚动停在顶上);窄屏预览排在
|
||||
* 频率下面,改了频率当场看得到。
|
||||
*
|
||||
* schedule 为 null 是新建。修改时只交改了的字段,带上 version:详情页页头的开关改过之后,version 是开关交回的
|
||||
* 新版本,表单照常保存;别人在这期间改过就 409,表单不动,给「重新载入」。
|
||||
*/
|
||||
export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
|
||||
schedule: Schedule | null
|
||||
/** 保存时带的版本;不给用 schedule 自己的 */
|
||||
version?: number
|
||||
onSaved: (saved: Schedule, created: boolean) => void
|
||||
/** 版本不符时重新读服务端的设置(详情页重置表单) */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
const workflows = useCatalog((s) => s.workflows)
|
||||
const workflowsState = useCatalogListState('workflows')
|
||||
const [base, setBase] = useState<Schedule | null>(schedule)
|
||||
const [initial, setInitial] = useState<ScheduleForm>(() => formOf(schedule))
|
||||
const [form, setForm] = useState<ScheduleForm>(initial)
|
||||
const [touched, setTouched] = useState<Partial<Record<FormField, boolean>>>({})
|
||||
/** 新建时没动过运行类别:选工作流时按它发没发布替人选上 */
|
||||
const runClassTouched = useRef(!!schedule)
|
||||
/** 名称是选工作流时替人填的哪个名字:人没改过它时,换工作流名称跟着换;人改过就不再动 */
|
||||
const autoName = useRef<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
/**
|
||||
* 上一次保存的失败。field 是服务端 422 指的那一项,at 是交上去时那一项的值(serverFieldValue):
|
||||
* 人改了那一项,说明就不再摆着,免得看上去新值也不行
|
||||
*/
|
||||
const [saveError, setSaveError] = useState<{
|
||||
message: string; detail?: string; conflict?: boolean; field?: string; at?: string | null
|
||||
} | null>(null)
|
||||
|
||||
const workflow = workflows.find((w) => w.id === form.workflowId)
|
||||
const cron = cronOf(form.freq)
|
||||
const template = useMemo(() => templateOf(form.inputs), [form.inputs])
|
||||
const preview = usePreview(cron, form.timezone, template, form.workflowId)
|
||||
const fieldsOf = useInputFields(workflow, form.runClass)
|
||||
// 修改时工作流列表还没取回来:输入字段是「正在读取」,不是「请先选择工作流」
|
||||
const fields: FieldsState = !workflow && form.workflowId && workflowsState !== 'ok' ? { status: 'loading' } : fieldsOf
|
||||
const variables = useVariables()
|
||||
|
||||
const changes = base ? patchBody(form, initial, base) : null
|
||||
// 服务端保存时只在新建、或者改了工作流、运行类别、输入时才核对这三样:照着拦,不多拦也不少拦
|
||||
const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes))
|
||||
const problems = formProblems(form, base, {
|
||||
published: workflow ? workflow.published_version ?? null : undefined,
|
||||
fields: fields.status === 'ok' ? fields.fields : undefined,
|
||||
recheck,
|
||||
})
|
||||
const dirty = base ? changes != null : JSON.stringify(form) !== JSON.stringify(initial)
|
||||
const previewField = preview.status === 'invalid' ? preview.field : undefined
|
||||
/** 预览判定非法、并指明是这一项时,服务端的说明 */
|
||||
const previewSays = (field: string) => (preview.status === 'invalid' && preview.field === field ? preview.message : undefined)
|
||||
const previewBlocks = previewField === 'cron' || previewField === 'timezone' || previewField === 'input_template'
|
||||
const missing = (Object.keys(problems) as FormField[]).map((k) => T.problemField[k])
|
||||
const hint = missing.length ? T.missing(missing.join('、')) : previewBlocks ? T.previewBlocks : ''
|
||||
const canSave = !saving && !missing.length && !previewBlocks && dirty
|
||||
|
||||
useLeaveGuard(dirty && !saving, () => confirmDialog({
|
||||
title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true,
|
||||
}))
|
||||
|
||||
const set = (patch: Partial<ScheduleForm>) => setForm((f) => ({ ...f, ...patch }))
|
||||
const setFreq = (patch: Partial<Freq>) => setForm((f) => ({ ...f, freq: { ...f.freq, ...patch } }))
|
||||
const touch = (k: FormField) => setTouched((t) => (t[k] ? t : { ...t, [k]: true }))
|
||||
|
||||
const pickWorkflow = (w: Workflow) => {
|
||||
if (w.id === form.workflowId) return
|
||||
// 名字空着、或者还是上次替人填的工作流名:跟着换成新工作流的名字。在这里判、不在 setForm 的回调里改 ref:
|
||||
// 回调在严格模式下会调两遍
|
||||
const follow = !form.name.trim() || form.name === autoName.current
|
||||
if (follow) autoName.current = w.name
|
||||
setForm((f) => {
|
||||
// 换了工作流:新工作流里没有的字段的模板丢掉(同名的留着)
|
||||
const keep = new Set(graphFields(w.graph).map((x) => x.name))
|
||||
const inputs = Object.fromEntries(Object.entries(f.inputs).filter(([k]) => keep.has(k)))
|
||||
const runClass: ScheduleRunClass = runClassTouched.current ? f.runClass : w.published_version ? 'formal' : 'exploratory'
|
||||
return { ...f, workflowId: w.id, name: follow ? w.name : f.name, inputs, runClass }
|
||||
})
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return
|
||||
const sent = form
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
let saved: Schedule
|
||||
if (!base) {
|
||||
saved = await api.schedules.create(createBody(sent))
|
||||
} else {
|
||||
const body = patchBody(sent, initial, base)
|
||||
if (!body) return
|
||||
saved = await api.schedules.update(base.id, { ...body, version: version ?? base.version })
|
||||
}
|
||||
const next = formOf(saved)
|
||||
setBase(saved)
|
||||
setInitial(next)
|
||||
// 保存的这段时间里人又改了:只留途中改的那几项(相对刚存下的设置照样算未保存),其余换成刚存下的。
|
||||
// 整份留着的话,交上去的替换地址会以完整明文留在框里,下次保存再发一遍
|
||||
setForm((cur) => rebaseForm(cur, sent, next))
|
||||
setTouched({})
|
||||
if (base) toast.ok(T.saved)
|
||||
onSaved(saved, !base)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
setSaveError({ message: T.conflict, detail: e.message, conflict: true })
|
||||
} else if (isUnsupported(e)) {
|
||||
setSaveError({ message: T.unsupportedHint })
|
||||
} else if (e instanceof ApiError && e.kind === 'http' && e.status >= 400 && e.status < 500) {
|
||||
setSaveError({ message: e.message, field: e.field, at: e.field ? serverFieldValue(e.field, sent) : null })
|
||||
} else {
|
||||
toast.error(e)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
if (dirty && !(await confirmDialog({
|
||||
title: T.reloadTitle, consequences: T.reloadConsequences, confirmLabel: T.reloadConfirm, danger: true,
|
||||
}))) return
|
||||
onReload?.()
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
setForm(initial)
|
||||
setTouched({})
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
// 保存被 422 拒了、服务端指了是哪一项:写在那一项下面;那一项改过之后不再摆着(认不出的项留到下次保存)
|
||||
const stillSays = !!saveError && (saveError.at == null || !saveError.field || serverFieldValue(saveError.field, form) === saveError.at)
|
||||
const serverError = (keys: string[]) => (stillSays && saveError?.field && keys.includes(saveError.field) ? saveError.message : undefined)
|
||||
const shown = (k: FormField) => (touched[k] ? problems[k] : undefined)
|
||||
const nameError = shown('name') ?? serverError(['name'])
|
||||
const misfireError = serverError(['misfire_grace_s'])
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mx-auto grid max-w-5xl gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_300px]"
|
||||
noValidate
|
||||
onSubmit={(e) => { e.preventDefault(); void save() }}
|
||||
data-schedule-form=""
|
||||
>
|
||||
<div className="min-w-0 space-y-4 lg:col-start-1">
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5">
|
||||
<Field label={T.name} htmlFor="schedule-name" required error={nameError}>
|
||||
<input id="schedule-name" className="field" value={form.name} placeholder={T.namePlaceholder} autoComplete="off"
|
||||
maxLength={200} aria-invalid={!!nameError || undefined}
|
||||
aria-describedby={nameError ? 'schedule-name-error' : undefined}
|
||||
onChange={(e) => set({ name: e.target.value })} onBlur={() => touch('name')} data-field="name" />
|
||||
</Field>
|
||||
<WorkflowField form={form} workflows={workflows} state={workflowsState} workflow={workflow} base={base}
|
||||
error={shown('workflow') ?? serverError(['workflow_id'])}
|
||||
onPick={pickWorkflow} onBlur={() => touch('workflow')} />
|
||||
</section>
|
||||
|
||||
<FreqSection form={form} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
|
||||
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
|
||||
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
|
||||
<div className="lg:sticky lg:top-4">
|
||||
<PreviewBox preview={preview} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 space-y-4 lg:col-start-1">
|
||||
<RunClassSection value={form.runClass} workflow={workflow} problem={problems.runClass}
|
||||
error={serverError(['run_class'])}
|
||||
onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} />
|
||||
<InputsSection workflow={workflow} fields={fields} inputs={form.inputs} variables={variables}
|
||||
preview={preview} recheck={recheck}
|
||||
error={previewSays('input_template') ?? serverError(['input_template'])}
|
||||
onChange={(inputs) => set({ inputs })} />
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-overlap-title">
|
||||
<h2 id="schedule-overlap-title" className="text-xs font-semibold">{T.overlap}</h2>
|
||||
<p className="text-xs leading-relaxed text-dim">{T.overlapText}</p>
|
||||
<Field label={T.misfire} htmlFor="schedule-misfire" hint={T.misfireHint} error={misfireError}>
|
||||
<select id="schedule-misfire" className="field w-auto max-w-full" value={form.misfireGraceS}
|
||||
aria-invalid={!!misfireError || undefined}
|
||||
aria-describedby={misfireError ? 'schedule-misfire-error' : 'schedule-misfire-hint'}
|
||||
onChange={(e) => set({ misfireGraceS: Number(e.target.value) })} data-misfire="">
|
||||
{[...new Set([...GRACE_OPTIONS, form.misfireGraceS])].sort((a, b) => a - b).map((s) => (
|
||||
<option key={s} value={s}>{T.misfireOption(graceLabel(s))}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{!base && (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => set({ enabled: e.target.checked })} data-enabled-on-create="" />
|
||||
{T.enabledOnCreate}
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
<WebhookFields value={form.webhook} onChange={(webhook) => set({ webhook })} saved={base?.webhook ?? null}
|
||||
problems={{ ...problems, url: problems.url ?? serverError(['webhook', 'webhook.url']) }}
|
||||
scheduleId={base?.id ?? null} unsaved={!!changes?.webhook} />
|
||||
</div>
|
||||
|
||||
{/* 有要保存的、或者有要看的报错时才贴在底部:没改动时一条空的保存栏压在表单上只是挡字 */}
|
||||
<div className={clsx('z-10 space-y-2 rounded-lg border bg-panel px-3 py-2.5 lg:col-span-2',
|
||||
(dirty || saveError) && 'sticky bottom-0 shadow-elev-2')} data-schedule-savebar="">
|
||||
{saveError?.conflict && (
|
||||
<Notice tone="err" attr={{ 'data-schedule-conflict': '' }}>
|
||||
<div>{saveError.message}</div>
|
||||
{saveError.detail && <div className="text-faint">{saveError.detail}</div>}
|
||||
<button type="button" className="btn btn-sm mt-1.5" onClick={() => void reload()} data-schedule-reload="">{T.reload}</button>
|
||||
</Notice>
|
||||
)}
|
||||
{saveError && !saveError.conflict && stillSays && (
|
||||
<Notice tone="err" attr={{ 'data-schedule-save-error': '' }}>{T.saveFailed}:{saveError.message}</Notice>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{hint && <span className="mr-auto min-w-0 text-2xs leading-relaxed text-faint" data-schedule-hint="">{hint}</span>}
|
||||
{base
|
||||
? dirty && <button type="button" className="btn" onClick={discard} disabled={saving}>{T.discard}</button>
|
||||
: <Link to="/schedules" className="btn">{T.cancel}</Link>}
|
||||
<button type="submit" className="btn btn-primary" disabled={!canSave} title={hint || undefined} data-schedule-save="">
|
||||
{saving && <Spinner size={11} />} {saving ? T.saving : base ? T.save : T.createSubmit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作流
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WorkflowField({ form, workflows, state, workflow, base, error, onPick, onBlur }: {
|
||||
form: ScheduleForm; workflows: Workflow[]; state: string; workflow: Workflow | undefined; base: Schedule | null
|
||||
error?: string; onPick: (w: Workflow) => void; onBlur: () => void
|
||||
}) {
|
||||
// 指向的工作流已经不在列表里(被删了):照实说,不让人以为选的还是它
|
||||
const gone = state === 'ok' && !!form.workflowId && !workflow
|
||||
const message = error ?? (gone ? T.workflowGone(base?.workflow_name ?? form.workflowId) : undefined)
|
||||
return (
|
||||
<Field label={T.workflow} htmlFor="schedule-workflow" required error={message}
|
||||
hint={state === 'ok' && !workflows.length ? <>{T.workflowEmpty} <Link to="/studio" className="underline">{T.toStudio}</Link></> : undefined}>
|
||||
<WorkflowPicker id="schedule-workflow" value={form.workflowId} workflows={workflows} onChange={onPick} onBlur={onBlur}
|
||||
fallbackLabel={base?.workflow_name} invalid={!!message} describedBy={message ? 'schedule-workflow-error' : undefined} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 频率与时区
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FreqSection({ form, setFreq, setTimezone, freqError, tzError }: {
|
||||
form: ScheduleForm
|
||||
setFreq: (patch: Partial<Freq>) => void
|
||||
setTimezone: (tz: string) => void
|
||||
freqError?: string
|
||||
tzError?: string
|
||||
}) {
|
||||
const f = form.freq
|
||||
const pick = (preset: FreqPreset) => {
|
||||
if (preset === f.preset) return
|
||||
// 切到自定义:带上当前预设生成的 cron,在它的基础上改
|
||||
if (preset === 'custom') setFreq({ preset, cron: cronOf(f) || f.cron })
|
||||
else setFreq({ preset })
|
||||
}
|
||||
const radio = useRadioGroup(FREQ_PRESETS, f.preset, pick)
|
||||
const errId = 'schedule-freq-error'
|
||||
// 说明接到出问题的那一项上:每周一天都没选时是星期那一组,否则是这个预设主要的输入框
|
||||
const weekdayError = !!freqError && f.preset === 'weekly' && !f.weekdays.length
|
||||
const errorProps = (primary: boolean) => (primary && freqError && !weekdayError
|
||||
? { 'aria-invalid': true as const, 'aria-describedby': errId } : {})
|
||||
const zones = useMemo(() => timezoneOptions(form.timezone), [form.timezone])
|
||||
const timeInput = (
|
||||
<Field label={T.time} htmlFor="schedule-time">
|
||||
<input id="schedule-time" type="time" step={60} className="field w-32" value={f.time} required
|
||||
onChange={(e) => setFreq({ time: e.target.value })} data-freq-time="" {...errorProps(true)} />
|
||||
</Field>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-freq-title">
|
||||
<h2 id="schedule-freq-title" className="text-xs font-semibold">{T.freq}</h2>
|
||||
<div role="radiogroup" aria-labelledby="schedule-freq-title" className="flex flex-wrap gap-1">
|
||||
{FREQ_PRESETS.map((p) => (
|
||||
<button key={p} type="button" {...radio(p)} onClick={() => pick(p)} data-freq-preset={p}
|
||||
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
f.preset === p ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{T.preset[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
|
||||
{f.preset === 'weekly' && (
|
||||
<div className="min-w-0">
|
||||
<div className="label" id="schedule-weekdays">{T.weekdays}</div>
|
||||
<div role="group" aria-labelledby="schedule-weekdays" aria-describedby={weekdayError ? errId : undefined}
|
||||
className="flex flex-wrap gap-1">
|
||||
{WEEKDAY_ORDER.map((d) => {
|
||||
const on = f.weekdays.includes(d)
|
||||
return (
|
||||
<button key={d} type="button" aria-pressed={on} data-weekday={d}
|
||||
onClick={() => setFreq({ weekdays: on ? f.weekdays.filter((x) => x !== d) : [...f.weekdays, d] })}
|
||||
className={clsx('h-7 min-w-9 rounded-md border px-1.5 text-xs transition-colors',
|
||||
on ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{T.weekdayShort[d]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{f.preset === 'monthly' && (
|
||||
<Field label={T.monthday} htmlFor="schedule-monthday" hint={f.monthday > 28 ? T.monthdayHint : undefined}>
|
||||
<select id="schedule-monthday" className="field w-24" value={f.monthday} data-freq-monthday=""
|
||||
aria-describedby={f.monthday > 28 ? 'schedule-monthday-hint' : undefined}
|
||||
onChange={(e) => setFreq({ monthday: Number(e.target.value) })}>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => <option key={d} value={d}>{T.monthdayOption(d)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{(f.preset === 'daily' || f.preset === 'weekly' || f.preset === 'monthly') && timeInput}
|
||||
{f.preset === 'hourly' && (
|
||||
<>
|
||||
<Field label={T.everyHours} htmlFor="schedule-every">
|
||||
<select id="schedule-every" className="field w-32" value={f.everyHours} data-freq-every=""
|
||||
onChange={(e) => setFreq({ everyHours: Number(e.target.value) })}>
|
||||
{HOUR_STEPS.map((n) => <option key={n} value={n}>{T.everyHoursOption(n)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={T.minute} htmlFor="schedule-minute">
|
||||
<input id="schedule-minute" type="number" min={0} max={59} step={1} className="field w-20" data-freq-minute=""
|
||||
value={Number.isFinite(f.minute) ? f.minute : ''} {...errorProps(true)}
|
||||
onChange={(e) => setFreq({ minute: e.target.value === '' ? Number.NaN : Number(e.target.value) })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{f.preset === 'custom' && (
|
||||
<Field label={T.cron} htmlFor="schedule-cron" hint={freqError ? undefined : T.cronHint} className="min-w-0 flex-1 basis-56">
|
||||
<input id="schedule-cron" className="field mono" value={f.cron} placeholder="0 9 * * 1-5" autoComplete="off" spellCheck={false}
|
||||
onChange={(e) => setFreq({ cron: e.target.value })} data-freq-cron=""
|
||||
{...(freqError ? errorProps(true) : { 'aria-describedby': 'schedule-cron-hint' })} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{f.preset === 'hourly' && !freqError && (
|
||||
<p className="text-2xs leading-relaxed text-faint">{T.hourlyHint(f.everyHours, f.minute)}</p>
|
||||
)}
|
||||
{freqError && (
|
||||
<div id={errId} className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-freq-error="">{freqError}</div>
|
||||
)}
|
||||
|
||||
<Field label={T.timezone} htmlFor="schedule-timezone" hint={T.timezoneHint} error={tzError}>
|
||||
<select id="schedule-timezone" className="field" value={form.timezone} data-timezone=""
|
||||
aria-invalid={!!tzError || undefined}
|
||||
aria-describedby={tzError ? 'schedule-timezone-error' : 'schedule-timezone-hint'}
|
||||
onChange={(e) => setTimezone(e.target.value)}>
|
||||
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 预览
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PreviewState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; data?: SchedulePreview; timezone?: string }
|
||||
| { status: 'ok'; data: SchedulePreview; timezone: string }
|
||||
| { status: 'invalid'; message: string; field?: string }
|
||||
| { status: 'error'; error: unknown }
|
||||
|
||||
/** 预览防抖:停手 400ms 再问,新的一次发出时作废上一次 */
|
||||
const PREVIEW_DEBOUNCE_MS = 400
|
||||
|
||||
/**
|
||||
* 接下来 5 次触发和渲染出的输入(服务端算,前端不自己解析 cron)。改动中保留上一次的结果并标「正在计算」;
|
||||
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题
|
||||
*/
|
||||
function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string): PreviewState {
|
||||
const [state, setState] = useState<PreviewState>({ status: 'idle' })
|
||||
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId])
|
||||
useEffect(() => {
|
||||
if (!cron || !timezone) {
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
setState((s) => (s.status === 'ok' ? { status: 'loading', data: s.data, timezone: s.timezone }
|
||||
: s.status === 'loading' ? s : { status: 'loading' }))
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
api.schedules.preview({
|
||||
cron, timezone, n: 5,
|
||||
...(Object.keys(template).length ? { input_template: template } : {}),
|
||||
...(workflowId ? { workflow_id: workflowId } : {}),
|
||||
}, { signal: ctrl.signal })
|
||||
.then((data) => setState({ status: 'ok', data, timezone }))
|
||||
.catch((e) => {
|
||||
if ((e as { name?: string })?.name === 'AbortError') return
|
||||
if (e instanceof ApiError && e.status === 422 && !isUnsupported(e)) {
|
||||
setState({ status: 'invalid', message: e.message, field: e.field })
|
||||
} else {
|
||||
setState({ status: 'error', error: e })
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ctrl.abort()
|
||||
}
|
||||
// key 已经把四项都编进去了
|
||||
}, [key])
|
||||
return state
|
||||
}
|
||||
|
||||
function PreviewBox({ preview }: { preview: PreviewState }) {
|
||||
const data = preview.status === 'ok' || preview.status === 'loading' ? preview.data : undefined
|
||||
const tz = preview.status === 'ok' || preview.status === 'loading' ? preview.timezone : undefined
|
||||
return (
|
||||
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-preview-title" aria-live="polite"
|
||||
data-schedule-preview="" data-status={preview.status}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h2 id="schedule-preview-title" className="text-xs font-semibold">{T.preview}</h2>
|
||||
{preview.status === 'loading' && <Spinner size={11} />}
|
||||
</div>
|
||||
{data && (
|
||||
<>
|
||||
{data.description_text && <div className="mb-2 text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
|
||||
{data.next?.length ? (
|
||||
<ol className={clsx('space-y-1', preview.status === 'loading' && 'opacity-60')}>
|
||||
{data.next.map((at, i) => {
|
||||
// 和列表、触发历史一个口径:不带时区后缀的按 UTC 读(new Date 会当成本机时间),读不了的照原文写
|
||||
const d = parseServerTime(at)
|
||||
return (
|
||||
<li key={`${at}-${i}`} className="flex items-baseline gap-2 text-xs" data-preview-time="">
|
||||
<span className="tnum w-4 shrink-0 text-right text-faint">{i + 1}</span>
|
||||
<time className="tnum" dateTime={d?.toISOString()}>
|
||||
{d && tz ? formatZoned(d, tz, 'full') : at}
|
||||
</time>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : <p className="text-xs text-faint">{T.previewNone}</p>}
|
||||
{tz && <p className="mt-2 text-2xs text-faint">{T.previewZone(tz)}</p>}
|
||||
</>
|
||||
)}
|
||||
{!data && preview.status === 'loading' && <p className="text-xs text-faint">{T.previewLoading}</p>}
|
||||
{preview.status === 'idle' && <p className="text-xs text-faint">{T.previewIdle}</p>}
|
||||
{preview.status === 'invalid' && <p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">{preview.message}</p>}
|
||||
{preview.status === 'error' && (
|
||||
<p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">
|
||||
{isUnsupported(preview.error) ? T.unsupported : `${T.previewFailed}:${errorMessage(preview.error)}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 运行类别
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RUN_CLASSES: readonly ScheduleRunClass[] = ['formal', 'exploratory']
|
||||
|
||||
/**
|
||||
* 运行类别。正式运行要工作流已发布:新建、或者这次改了工作流 / 运行类别时服务端保存就会拒,problem 写在这里、
|
||||
* 保存按钮跟着禁用;已保存的定时任务没动这两样、工作流后来撤了发布时保存照常,只提醒到点会发起失败
|
||||
*/
|
||||
function RunClassSection({ value, workflow, problem, error, onChange }: {
|
||||
value: ScheduleRunClass; workflow: Workflow | undefined
|
||||
/** 保存前要处理的问题(formProblems 的 runClass) */
|
||||
problem?: string
|
||||
/** 保存被拒、服务端指明是运行类别时的说明 */
|
||||
error?: string
|
||||
onChange: (v: ScheduleRunClass) => void
|
||||
}) {
|
||||
const radio = useRadioGroup(RUN_CLASSES, value, onChange)
|
||||
const unpublished = !!workflow && !workflow.published_version
|
||||
const describedBy = [problem ? 'schedule-class-problem' : null, error ? 'schedule-class-error' : null].filter(Boolean).join(' ')
|
||||
return (
|
||||
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-class-title">
|
||||
<h2 id="schedule-class-title" className="text-xs font-semibold">{T.runClass}</h2>
|
||||
<div role="radiogroup" aria-labelledby="schedule-class-title" aria-describedby={describedBy || undefined}
|
||||
className="grid gap-2 sm:grid-cols-2">
|
||||
{RUN_CLASSES.map((rc) => {
|
||||
const on = value === rc
|
||||
return (
|
||||
<button key={rc} type="button" {...radio(rc)} onClick={() => onChange(rc)} data-run-class={rc}
|
||||
className={clsx('relative flex flex-col justify-start rounded-lg border px-3 py-2.5 text-left transition-colors',
|
||||
on ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')}>
|
||||
{on && (
|
||||
<span className="absolute right-2 top-2 flex h-4 w-4 items-center justify-center rounded-full bg-accent-solid text-on-accent" aria-hidden>
|
||||
<Check size={10} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
<span className="pr-5 text-xs font-semibold">{rc === 'formal' ? T.formal : T.exploratory}</span>
|
||||
<span className="mt-1 text-2xs leading-relaxed text-faint">{rc === 'formal' ? T.formalHint : T.exploratoryHint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{problem ? (
|
||||
<Notice tone="err" attr={{ id: 'schedule-class-problem', 'data-run-class-problem': '' }}>{problem}</Notice>
|
||||
) : value === 'formal' && unpublished && (
|
||||
<Notice tone="warn" attr={{ 'data-not-published': '' }}>{T.notPublished}</Notice>
|
||||
)}
|
||||
{value === 'formal' && workflow?.published_version != null && (
|
||||
<p className="text-2xs leading-relaxed text-faint">{T.formalVersion(workflow.published_version)}</p>
|
||||
)}
|
||||
{error && (
|
||||
<div id="schedule-class-error" className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-run-class-error="">{error}</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 已发布版本的输入字段,按(工作流, 版本)记一份:发布之后那一版就不会再变 */
|
||||
const versionFields = new Map<string, InputField[]>()
|
||||
|
||||
type FieldsState =
|
||||
| { status: 'none' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: unknown; retry: () => void }
|
||||
| { status: 'ok'; fields: InputField[]; version: number | null }
|
||||
|
||||
/**
|
||||
* 这个工作流的输入字段。正式运行到点按当时的发布版本发起,字段取已发布的那一版(和画布可能不同);
|
||||
* 探索运行用工作流当前保存的内容,字段取它的入口节点
|
||||
*/
|
||||
function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunClass): FieldsState {
|
||||
const published = runClass === 'formal' && workflow?.published_version ? workflow.published_version : null
|
||||
const key = workflow && published != null ? `${workflow.id}@${published}` : ''
|
||||
const [fetched, setFetched] = useState<{ key: string; state: FieldsState } | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !workflow || published == null || versionFields.has(key)) return
|
||||
let alive = true
|
||||
setFetched({ key, state: { status: 'loading' } })
|
||||
api.workflows.version(workflow.id, published)
|
||||
.then((v) => {
|
||||
const list = v.input_fields?.length ? (v.input_fields as InputField[]) : graphFields(v.graph)
|
||||
versionFields.set(key, list)
|
||||
if (alive) setFetched({ key, state: { status: 'ok', fields: list, version: published } })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (alive) setFetched({ key, state: { status: 'error', error, retry: () => setAttempt((n) => n + 1) } })
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [key, attempt])
|
||||
|
||||
if (!workflow) return { status: 'none' }
|
||||
if (published == null) return { status: 'ok', fields: graphFields(workflow.graph), version: null }
|
||||
const hit = versionFields.get(key)
|
||||
if (hit) return { status: 'ok', fields: hit, version: published }
|
||||
return fetched?.key === key ? fetched.state : { status: 'loading' }
|
||||
}
|
||||
|
||||
/** 输入模板里能用的变量:服务端给,各处共用一份;取不到时为空表,变量按钮照样在,只是说没有可用的 */
|
||||
let variablesOnce: Promise<ScheduleVariable[]> | null = null
|
||||
function useVariables(): ScheduleVariable[] | null {
|
||||
const [vars, setVars] = useState<ScheduleVariable[] | null>(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
variablesOnce ??= api.schedules.variables().then((v) => (Array.isArray(v) ? v : []), (e) => {
|
||||
variablesOnce = null
|
||||
throw e
|
||||
})
|
||||
variablesOnce.then((v) => { if (alive) setVars(v) }, () => { if (alive) setVars([]) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
return vars
|
||||
}
|
||||
|
||||
const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
|
||||
|
||||
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, error, onChange }: {
|
||||
workflow: Workflow | undefined
|
||||
fields: FieldsState
|
||||
inputs: Record<string, string>
|
||||
variables: ScheduleVariable[] | null
|
||||
preview: PreviewState
|
||||
/** 保存时服务端会不会核对输入(新建,或者改了工作流、运行类别、输入):会的话必填没填、字段对不上都要先处理 */
|
||||
recheck: boolean
|
||||
error?: string
|
||||
onChange: (inputs: Record<string, string>) => void
|
||||
}) {
|
||||
const refs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const rendered = preview.status === 'ok' ? preview.data.inputs ?? {} : null
|
||||
const names = fields.status === 'ok' ? new Set(fields.fields.map((f) => f.name)) : null
|
||||
// 对不上的字段:只列填了值的(留空的本来就不交)
|
||||
const stale = names ? Object.keys(inputs).filter((k) => !names.has(k) && inputs[k]?.trim()) : []
|
||||
// 正式运行的字段取已发布的版本:画布上有、那一版里没有的,说清是哪一版里没有,不说成工作流里已经删了
|
||||
const draft = useMemo(() => new Set(graphFields(workflow?.graph).map((f) => f.name)), [workflow])
|
||||
const staleText = (name: string) => {
|
||||
const reason = fields.status === 'ok' && fields.version != null && draft.has(name) ? T.staleNotInVersion(fields.version) : T.staleField
|
||||
// 入口声明了字段时服务端只收这些,交上去会被拒;入口什么都没声明时什么键都收
|
||||
return recheck && fields.status === 'ok' && fields.fields.length ? `${reason},${T.staleRemove}` : reason
|
||||
}
|
||||
|
||||
const setValue = (name: string, value: string) => onChange({ ...inputs, [name]: value })
|
||||
/** 变量插在光标处(选中了文字就替换它),插完光标落在变量后面 */
|
||||
const insert = (name: string, token: string) => {
|
||||
const el = refs.current[name]
|
||||
const cur = inputs[name] ?? ''
|
||||
const start = el?.selectionStart ?? cur.length
|
||||
const end = el?.selectionEnd ?? cur.length
|
||||
setValue(name, cur.slice(0, start) + token + cur.slice(end))
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus()
|
||||
el?.setSelectionRange(start + token.length, start + token.length)
|
||||
})
|
||||
}
|
||||
|
||||
let body
|
||||
if (fields.status === 'none') body = <p className="text-xs text-faint">{T.inputsPick}</p>
|
||||
else if (fields.status === 'loading') body = <Skeleton rows={2} height={28} />
|
||||
else if (fields.status === 'error') {
|
||||
body = (
|
||||
<Notice tone="err">
|
||||
{T.inputsFailed}:{errorMessage(fields.error)}
|
||||
<button type="button" className="btn btn-xs ml-2" onClick={fields.retry}>{T.retry}</button>
|
||||
</Notice>
|
||||
)
|
||||
} else if (!fields.fields.length && !stale.length) {
|
||||
body = <p className="text-xs text-faint">{T.inputsNone}</p>
|
||||
} else {
|
||||
body = (
|
||||
<div className="space-y-3">
|
||||
{fields.fields.map((field) => {
|
||||
const value = inputs[field.name] ?? ''
|
||||
const id = `schedule-input-${field.name}`
|
||||
const varsId = `${id}-vars`
|
||||
const noDefault = field.required && (field.default == null || shownValue(field.default) === '')
|
||||
const out = rendered && value.trim() && field.name in rendered ? shownValue(rendered[field.name]) : null
|
||||
return (
|
||||
<div key={field.name} className="min-w-0" data-input-field={field.name}>
|
||||
<label className="label flex flex-wrap items-baseline gap-x-1.5" htmlFor={id}>
|
||||
<span className="mono text-fg">{field.name}</span>
|
||||
{field.required && <span className="text-[var(--err)]" aria-label={T.required} data-required="">*</span>}
|
||||
{field.description && <span className="font-normal text-faint">{field.description}</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id={id} ref={(el) => { refs.current[field.name] = el }} className="field mono min-w-0 flex-1 basis-48" value={value}
|
||||
placeholder={field.default != null && shownValue(field.default) !== '' ? T.defaultValue(shownValue(field.default)) : ''}
|
||||
autoComplete="off" spellCheck={false} onChange={(e) => setValue(field.name, e.target.value)}
|
||||
aria-describedby={[out != null ? `${id}-rendered` : null, noDefault && !value.trim() ? `${id}-required` : null]
|
||||
.filter(Boolean).join(' ') || undefined} />
|
||||
<button type="button" className="btn btn-sm" aria-expanded={open === field.name} aria-controls={varsId}
|
||||
onClick={() => setOpen((o) => (o === field.name ? null : field.name))} data-input-vars={field.name}>
|
||||
{open === field.name ? T.variablesClose : T.variables}
|
||||
</button>
|
||||
</div>
|
||||
{open === field.name && (
|
||||
<div id={varsId} role="group" aria-label={T.variablesLabel(field.name)} className="mt-1.5 flex flex-wrap gap-1">
|
||||
{variables === null ? <Spinner size={11} /> : variables.length ? variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-xs" title={T.variableTitle(v.name, v.example)}
|
||||
onClick={() => insert(field.name, `{{ ${v.name} }}`)} data-var={v.name}>
|
||||
{v.label}
|
||||
</button>
|
||||
)) : <span className="text-2xs text-faint">{T.variablesEmpty}</span>}
|
||||
</div>
|
||||
)}
|
||||
{out != null && (
|
||||
<div id={`${id}-rendered`} className="mt-1 break-all text-2xs text-dim" data-input-rendered={field.name}>
|
||||
{out === '' ? T.renderedEmpty : T.rendered(out)}
|
||||
</div>
|
||||
)}
|
||||
{noDefault && !value.trim() && (
|
||||
<div id={`${id}-required`} className="mt-1 text-2xs" style={{ color: 'var(--warn)' }}>
|
||||
{recheck ? T.requiredFill : T.requiredNoDefault}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{stale.map((name) => (
|
||||
<div key={name} className="flex min-w-0 flex-wrap items-center gap-1.5 text-2xs" data-input-stale={name}>
|
||||
<span className="mono text-dim">{name}</span>
|
||||
<span className="mono min-w-0 truncate text-faint">{inputs[name]}</span>
|
||||
<span style={{ color: 'var(--warn)' }}>{staleText(name)}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={() => {
|
||||
const { [name]: _, ...rest } = inputs
|
||||
onChange(rest)
|
||||
}}>{T.removeField}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-inputs-title">
|
||||
<div>
|
||||
<h2 id="schedule-inputs-title" className="text-xs font-semibold">{T.inputs}</h2>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{T.inputsHint}</p>
|
||||
{workflow && fields.status === 'ok' && (
|
||||
<p className="mt-0.5 text-2xs text-faint" data-inputs-source="">
|
||||
{fields.version != null ? T.inputsFromVersion(fields.version) : T.inputsFromDraft}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{body}
|
||||
{error && <div className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-inputs-error="">{error}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,197 @@
|
||||
import { CalendarClock } from 'lucide-react'
|
||||
import { EmptyState, PageHeader } from '../../components/ui'
|
||||
import { SCHEDULE_TEXT } from '../../lib/terms'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { CalendarClock, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import { EmptyState, ErrorState, IconButton, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui'
|
||||
import { useOnReconnect } from '../../store/catalog'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import type { Schedule } from '../../types'
|
||||
import { ClassChip } from '../runs/parts'
|
||||
import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail'
|
||||
import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts'
|
||||
|
||||
/**
|
||||
* 定时任务(/schedules、/schedules/:id)。现在是占位页,定时任务的工作包补上列表和详情。
|
||||
* 定时任务:按设定的周期自动发起工作流运行。
|
||||
*
|
||||
* /schedules 是列表;/schedules/new 新建;/schedules/:id 是一个定时任务的详情(设置、触发历史两个标签,
|
||||
* ?tab=history 直接打开触发历史)。路由表在 App 里只有带 id、不带 id 两条,「new」在这里认。
|
||||
*/
|
||||
export function SchedulesPage() {
|
||||
const { id } = useParams()
|
||||
if (!id) return <ScheduleListPage />
|
||||
if (id === 'new') return <ScheduleCreatePage />
|
||||
return <ScheduleDetailPage key={id} id={id} />
|
||||
}
|
||||
|
||||
/** 列表每 30 秒原地刷新一次(页面不可见时不刷):到点触发之后,下次运行和上次触发要跟着变 */
|
||||
const LIST_REFRESH_MS = 30_000
|
||||
|
||||
function ScheduleListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [rows, setRows] = useState<Schedule[] | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
/** 每行正在做的事:开关、立即运行、删除各自禁用,不让同一行连点出两个请求 */
|
||||
const [busy, setBusy] = useState<Record<string, 'toggle' | 'run' | 'delete'>>({})
|
||||
const now = useTicker(30_000)
|
||||
/**
|
||||
* 读列表的序号:后发的读胜过先发的读;写操作成功时也加一,作废还在路上的读。那次读可能在写之前就读到了旧值、
|
||||
* 却在写的答复之后才回来,落地会把刚换上的那一行(开关的结果、删掉的行)整行盖回去
|
||||
*/
|
||||
const seq = useRef(0)
|
||||
const hasRows = useRef(false)
|
||||
const wrote = () => { seq.current++ }
|
||||
|
||||
const load = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
const n = ++seq.current
|
||||
try {
|
||||
const r = await api.schedules.list()
|
||||
if (n !== seq.current) return
|
||||
const items = r.items ?? []
|
||||
rememberScheduleNames(items)
|
||||
hasRows.current = true
|
||||
setRows(items)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (n !== seq.current) return
|
||||
// 读到过列表之后失败不把整页换成错误,手上的照旧是真数据:定时刷新不吭声,别的(重新载入)弹提示
|
||||
if (!hasRows.current) setError(e)
|
||||
else if (!opts?.silent) toast.error(e)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { void load() }, [load])
|
||||
useOnReconnect(() => { void load() })
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => { if (!document.hidden) void load({ silent: true }) }, LIST_REFRESH_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [load])
|
||||
|
||||
const replace = (next: Schedule) => setRows((rs) => rs && rs.map((r) => (r.id === next.id ? next : r)))
|
||||
const act = async (s: Schedule, what: 'toggle' | 'run' | 'delete', fn: () => Promise<void>) => {
|
||||
if (busy[s.id]) return
|
||||
setBusy((b) => ({ ...b, [s.id]: what }))
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
setBusy((b) => {
|
||||
const { [s.id]: _, ...rest } = b
|
||||
return rest
|
||||
})
|
||||
}
|
||||
}
|
||||
const toggle = (s: Schedule, enabled: boolean) => act(s, 'toggle', async () => {
|
||||
const out = await setEnabled(s, enabled)
|
||||
if (out === 'conflict') await load()
|
||||
else if (out) {
|
||||
wrote()
|
||||
replace(out)
|
||||
}
|
||||
})
|
||||
const run = (s: Schedule) => act(s, 'run', async () => {
|
||||
const out = await runNow(s, navigate)
|
||||
if (!out) return
|
||||
wrote()
|
||||
// 只换上次触发:这一行在请求途中可能被开关改过,不拿点下去那一刻的旧值整行盖回去
|
||||
setRows((rs) => rs && rs.map((r) => (r.id === s.id ? { ...r, last_fire: out.fire } : r)))
|
||||
// 下次运行、运行状态以服务端为准,再静默取一遍
|
||||
void load({ silent: true })
|
||||
})
|
||||
const remove = (s: Schedule) => act(s, 'delete', async () => {
|
||||
if (!(await removeSchedule(s))) return
|
||||
wrote()
|
||||
setRows((rs) => rs && rs.filter((r) => r.id !== s.id))
|
||||
})
|
||||
|
||||
const unsupported = isUnsupported(error)
|
||||
const createButton = (
|
||||
<Link to="/schedules/new" className="btn btn-primary btn-sm" data-schedule-create="">
|
||||
<Plus size={12} aria-hidden /> {T.create}
|
||||
</Link>
|
||||
)
|
||||
|
||||
let body
|
||||
if (unsupported) {
|
||||
body = (
|
||||
<div data-schedules-unsupported="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.unsupported} body={T.unsupportedHint} offline={false} />
|
||||
</div>
|
||||
)
|
||||
} else if (rows === null) {
|
||||
body = error ? <ErrorState error={error} onRetry={() => void load()} /> : <Skeleton rows={3} height={64} gap={8} />
|
||||
} else if (!rows.length) {
|
||||
body = (
|
||||
<div data-schedules-empty="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.emptyTitle} body={T.emptyBody} action={createButton} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<ul className="space-y-2" aria-label={T.listLabel} data-schedule-list="">
|
||||
{rows.map((s) => (
|
||||
<ScheduleRow key={s.id} s={s} now={now} busy={busy[s.id]}
|
||||
onToggle={(v) => void toggle(s, v)} onRun={() => void run(s)} onDelete={() => void remove(s)} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={SCHEDULE_TEXT.title} subtitle={SCHEDULE_TEXT.hint} />
|
||||
<div className="flex h-full flex-col" data-schedules-page="list">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={T.title} subtitle={T.hint} actions={unsupported ? null : createButton} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */}
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={SCHEDULE_TEXT.pending}
|
||||
body={SCHEDULE_TEXT.pendingHint} offline={false} />
|
||||
<div className="mx-auto max-w-4xl p-4">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行:名字(链到详情)、工作流和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。
|
||||
* 窄屏上右边的按钮整组折到下一行,不挤名字
|
||||
*/
|
||||
function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
|
||||
s: Schedule; now: number; busy?: 'toggle' | 'run' | 'delete'
|
||||
onToggle: (enabled: boolean) => void; onRun: () => void; onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-lg border bg-panel px-3 py-2.5" data-schedule-row={s.id} data-enabled={String(s.enabled)}>
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Link to={`/schedules/${encodeURIComponent(s.id)}`} data-schedule-name=""
|
||||
className="min-w-0 truncate text-sm font-medium text-fg hover:underline">
|
||||
{s.name}
|
||||
</Link>
|
||||
<ClassChip runClass={s.run_class} />
|
||||
{!s.enabled && <span className="chip shrink-0" data-schedule-paused="">{T.pausedChip}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-dim" title={`${s.workflow_name} · ${s.description_text}`} data-schedule-desc="">
|
||||
{s.workflow_name}<span className="text-faint"> · </span>{s.description_text}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<EnableSwitch checked={s.enabled} busy={busy === 'toggle'} label={T.enableLabel(s.name)} onChange={onToggle} />
|
||||
<button type="button" className="btn btn-sm" onClick={onRun} disabled={!!busy} aria-label={T.runNowLabel(s.name)}
|
||||
title={T.runNowLabel(s.name)} data-schedule-run="">
|
||||
{busy === 'run' ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {T.runNow}
|
||||
</button>
|
||||
<Link to={`/schedules/${encodeURIComponent(s.id)}`} className="btn btn-sm btn-ghost" aria-label={T.editLabel(s.name)}
|
||||
data-schedule-edit="">
|
||||
{T.edit}
|
||||
</Link>
|
||||
<IconButton label={T.deleteLabel(s.name)} onClick={onDelete} disabled={!!busy} data-schedule-delete=""
|
||||
className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={<Trash2 size={12} />} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1 text-2xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5" data-schedule-next="">
|
||||
<span className="shrink-0 text-faint">{T.nextRun}</span>
|
||||
<NextRun schedule={s} now={now} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5" data-schedule-last="">
|
||||
<span className="shrink-0 text-faint">{T.lastFire}</span>
|
||||
<LastFire fire={s.last_fire} tz={s.timezone} now={now} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Send } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import { Field, Notice, Spinner, isComposing, toast, useRadioGroup } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../types'
|
||||
import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, webhookTestText, type FormField, type WebhookForm } from './model'
|
||||
|
||||
/**
|
||||
* Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行链接用的服务访问地址。
|
||||
*
|
||||
* 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」,
|
||||
* 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置,
|
||||
* 页面上改了还没保存时不让测,免得测的不是眼前这份
|
||||
*/
|
||||
export function WebhookFields({ value, onChange, saved, problems, scheduleId, unsaved }: {
|
||||
value: WebhookForm
|
||||
onChange: (next: WebhookForm) => void
|
||||
/** 已保存的 Webhook;新建、原来没有时为 null */
|
||||
saved: ScheduleWebhook | null
|
||||
problems: Partial<Record<FormField, string>>
|
||||
/** 已保存的定时任务 id;新建时为 null,不能测试 */
|
||||
scheduleId: string | null
|
||||
/** Webhook 这一块有没保存的改动 */
|
||||
unsaved: boolean
|
||||
}) {
|
||||
const w = value
|
||||
const set = (patch: Partial<WebhookForm>) => onChange({ ...w, ...patch })
|
||||
const radio = useRadioGroup(WEBHOOK_FORMATS, w.format, (format) => set({ format }))
|
||||
const [urlTouched, setUrlTouched] = useState(false)
|
||||
const [test, setTest] = useState<{ busy: boolean; result?: ScheduleWebhookTest; error?: unknown } | null>(null)
|
||||
const askUrl = !saved || w.replaceUrl
|
||||
const urlError = askUrl && (urlTouched || w.url.trim()) ? problems.url : undefined
|
||||
const testBlocked = !scheduleId || !saved ? T.testNeedSave : unsaved ? T.testDirty : null
|
||||
|
||||
// 设置一改,上一次的测试结果就不代表眼前这份了
|
||||
useEffect(() => { setTest((t) => (t?.busy ? t : null)) }, [unsaved])
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!scheduleId || testBlocked) return
|
||||
setTest({ busy: true })
|
||||
try {
|
||||
setTest({ busy: false, result: await api.schedules.testWebhook(scheduleId) })
|
||||
} catch (e) {
|
||||
setTest({ busy: false, error: e })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-webhook-title" data-webhook-section="">
|
||||
<h2 id="schedule-webhook-title" className="mb-2.5 text-xs font-semibold">{T.webhook}</h2>
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={w.on} onChange={(e) => set({ on: e.target.checked })} data-webhook-on="" />
|
||||
<span>{T.webhookOn}</span>
|
||||
</label>
|
||||
|
||||
{!w.on && saved && (
|
||||
<Notice tone="warn" className="mt-2.5" attr={{ 'data-webhook-removing': '' }}>
|
||||
<span>{T.webhookRemoving}</span>
|
||||
<button type="button" className="btn btn-xs ml-2" onClick={() => set({ on: true })}>{T.webhookUndoRemove}</button>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{w.on && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="label" id="schedule-webhook-format">{T.webhookFormat}</div>
|
||||
<div role="radiogroup" aria-labelledby="schedule-webhook-format" className="flex flex-wrap gap-1">
|
||||
{WEBHOOK_FORMATS.map((f) => (
|
||||
<button key={f} type="button" {...radio(f)} onClick={() => set({ format: f })} data-webhook-format={f}
|
||||
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
w.format === f ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{T.format[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{askUrl ? (
|
||||
<Field label={T.url} htmlFor="schedule-webhook-url" error={urlError} hint={T.formatHint[w.format]}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-webhook-url" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-52"
|
||||
value={w.url} placeholder={T.urlPlaceholder[w.format]} autoComplete="off" spellCheck={false}
|
||||
aria-invalid={!!urlError || undefined}
|
||||
aria-describedby={urlError ? 'schedule-webhook-url-error' : 'schedule-webhook-url-hint'}
|
||||
onChange={(e) => set({ url: e.target.value })} onBlur={() => setUrlTouched(true)} data-webhook-url="" />
|
||||
{saved && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => set({ replaceUrl: false, url: '' })}>{T.urlKeep}</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label">{T.urlSaved}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mono min-w-0 truncate rounded-md border bg-elev px-2 py-1 text-xs text-dim" title={T.urlSavedHint}
|
||||
data-webhook-url-hint="">
|
||||
{saved?.url_hint}
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => set({ replaceUrl: true, url: '' })} data-webhook-replace="">
|
||||
{T.urlReplace}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => set({ on: false })} data-webhook-remove="">
|
||||
{T.webhookRemove}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-2xs leading-relaxed text-faint">{T.urlSavedHint}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SecretField value={w} saved={saved} onChange={set} error={problems.secret} />
|
||||
|
||||
<fieldset>
|
||||
<legend className="label">{T.events}</legend>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{SCHEDULE_EVENTS.map((ev) => (
|
||||
<label key={ev} className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={w.events.includes(ev)} data-webhook-event={ev}
|
||||
onChange={(e) => set({ events: toggled(w.events, ev, e.target.checked) })} />
|
||||
{T.event[ev]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{problems.events && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problems.events}</div>}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => void sendTest()} disabled={!!testBlocked || test?.busy}
|
||||
title={testBlocked ?? undefined} data-webhook-test="">
|
||||
{test?.busy ? <Spinner size={11} /> : <Send size={11} aria-hidden />} {test?.busy ? T.testing : T.test}
|
||||
</button>
|
||||
{testBlocked && <span className="text-2xs text-faint">{testBlocked}</span>}
|
||||
</div>
|
||||
{test && !test.busy && (
|
||||
<Notice tone={test.result?.ok ? 'info' : 'err'} attr={{ 'data-webhook-test-result': test.result?.ok ? 'ok' : 'failed' }}>
|
||||
{test.result
|
||||
? webhookTestText(test.result)
|
||||
: T.testResult(T.testFailed, null, errorMessage(test.error))}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<LinkBase />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const toggled = (events: ScheduleEvent[], ev: ScheduleEvent, on: boolean): ScheduleEvent[] =>
|
||||
on ? [...events.filter((x) => x !== ev), ev] : events.filter((x) => x !== ev)
|
||||
|
||||
/** 签名密钥:没设置过时是一个密码框;设置过时只说「已设置」,可以替换或清除 */
|
||||
function SecretField({ value: w, saved, onChange, error }: {
|
||||
value: WebhookForm; saved: ScheduleWebhook | null; onChange: (patch: Partial<WebhookForm>) => void; error?: string
|
||||
}) {
|
||||
const hasSecret = !!saved?.has_secret
|
||||
if (hasSecret && w.secretMode === 'keep') {
|
||||
return (
|
||||
<div>
|
||||
<div className="label">{T.secret}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="chip" data-webhook-secret-set="">{T.secretSet}</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'replace', secret: '' })}>{T.secretReplace}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'clear', secret: '' })}>{T.secretClear}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hasSecret && w.secretMode === 'clear') {
|
||||
return (
|
||||
<div>
|
||||
<div className="label">{T.secret}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-dim">
|
||||
<span>{T.secretClearing}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={() => onChange({ secretMode: 'keep' })}>{T.secretUndo}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Field label={hasSecret ? T.secretNew : T.secret} htmlFor="schedule-webhook-secret" error={error} hint={T.secretHint[w.format]}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-webhook-secret" type="password" className="field mono min-w-0 flex-1 basis-52" value={w.secret}
|
||||
autoComplete="new-password" spellCheck={false} aria-invalid={!!error || undefined}
|
||||
aria-describedby={error ? 'schedule-webhook-secret-error' : 'schedule-webhook-secret-hint'}
|
||||
onChange={(e) => onChange({ secret: e.target.value })} data-webhook-secret="" />
|
||||
{hasSecret && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'keep', secret: '' })}>{T.secretUndo}</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知里运行链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口,
|
||||
* 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
|
||||
*/
|
||||
function LinkBase() {
|
||||
const [value, setValue] = useState<string | null | undefined>(undefined)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
api.settings.get()
|
||||
.then((s) => { if (alive) setValue(typeof s?.notify?.public_base_url === 'string' ? s.notify.public_base_url : null) })
|
||||
.catch(() => { if (alive) setFailed(true) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const cleaned = draft.trim().replace(/\/+$/, '')
|
||||
const invalid = !!cleaned && !isHttpUrl(cleaned)
|
||||
const save = async () => {
|
||||
if (invalid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const current = await api.settings.get()
|
||||
const notify = { ...(current?.notify ?? {}), public_base_url: cleaned || null }
|
||||
const out = await api.settings.put({ notify })
|
||||
setValue(typeof out?.notify?.public_base_url === 'string' ? out.notify.public_base_url : notify.public_base_url)
|
||||
setFailed(false)
|
||||
setEditing(false)
|
||||
toast.ok(cleaned ? T.linkBaseSaved : T.linkBaseCleared)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-elev px-2.5 py-2" data-link-base="">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs">
|
||||
<span className="font-medium text-dim">{T.linkBase}</span>
|
||||
<span className={clsx('min-w-0 break-all', value ? 'mono text-fg' : 'text-faint')}>
|
||||
{failed ? T.linkBaseFailed : value === undefined ? <Spinner size={10} /> : value ? T.linkBaseValue(value) : T.linkBaseUnset}
|
||||
</span>
|
||||
{!editing && value !== undefined && (
|
||||
<button type="button" className="btn btn-xs" onClick={() => { setDraft(value ?? ''); setEditing(true) }} data-link-base-edit="">
|
||||
{value ? T.linkBaseEdit : T.linkBaseSet}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="mt-2">
|
||||
<Field label={T.linkBaseLabel} htmlFor="schedule-link-base" hint={T.linkBaseHint} error={invalid ? T.linkBaseInvalid : undefined}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-link-base" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-48" value={draft}
|
||||
placeholder={T.linkBasePlaceholder} autoComplete="off" spellCheck={false} aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? 'schedule-link-base-error' : 'schedule-link-base-hint'}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 在表单里:回车存这一项,不提交整张表单(输入法组字时的回车不算)
|
||||
if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() }
|
||||
}}
|
||||
data-link-base-input="" />
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()} disabled={invalid || busy} data-link-base-save="">
|
||||
{busy && <Spinner size={11} />} {T.save}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(false)}>{T.cancel}</button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useId, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { isComposing } from '../../components/ui'
|
||||
import { SCHEDULE_TEXT as T, WORKFLOW_STATUS_LABEL } from '../../lib/terms'
|
||||
import type { Workflow } from '../../types'
|
||||
|
||||
/** 工作流在下拉里的状态:「已发布 v3」「受管 v2」「草稿」 */
|
||||
export function workflowStatusText(w: Workflow): string {
|
||||
if (!w.published_version || !w.status || w.status === 'draft') return T.workflowDraft
|
||||
return T.workflowVersion(WORKFLOW_STATUS_LABEL[w.status], w.published_version)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可搜索的工作流选择(combobox + listbox)。聚焦就列出全部,打字按名称和说明筛;↑↓ 移动、回车选中、Esc 收起。
|
||||
* 没展开时输入框里写选中的那个的名字。选项按下时不抢焦点,点选不会先触发输入框失焦把列表收掉
|
||||
*/
|
||||
export function WorkflowPicker({ id, value, workflows, onChange, onBlur, fallbackLabel, invalid, describedBy }: {
|
||||
id: string
|
||||
value: string
|
||||
workflows: Workflow[]
|
||||
onChange: (w: Workflow) => void
|
||||
/** 选中的工作流不在列表里(列表还没取回来、或者已被删除)时,输入框里写的名字 */
|
||||
fallbackLabel?: string
|
||||
onBlur?: () => void
|
||||
invalid?: boolean
|
||||
describedBy?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [active, setActive] = useState(0)
|
||||
const listId = `${id}-list`
|
||||
const optionId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
const selected = workflows.find((w) => w.id === value)
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const sorted = [...workflows].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
return q ? sorted.filter((w) => w.name.toLowerCase().includes(q) || (w.description ?? '').toLowerCase().includes(q)) : sorted
|
||||
}, [workflows, query])
|
||||
|
||||
const pick = (w: Workflow) => {
|
||||
onChange(w)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
const show = () => {
|
||||
setOpen(true)
|
||||
setQuery('')
|
||||
setActive(Math.max(0, matches.findIndex((w) => w.id === value)))
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isComposing(e)) return
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!open) { show(); return }
|
||||
const step = e.key === 'ArrowDown' ? 1 : -1
|
||||
setActive((i) => Math.min(Math.max(0, i + step), Math.max(0, matches.length - 1)))
|
||||
} else if (e.key === 'Enter') {
|
||||
// 展开着时回车是选中,不是提交表单
|
||||
if (open && matches[active]) {
|
||||
e.preventDefault()
|
||||
pick(matches[active])
|
||||
}
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
// 只收起列表,不让外层(弹窗、页面)跟着处理 Esc
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" data-workflow-picker="">
|
||||
<input
|
||||
id={id}
|
||||
className="field"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && matches[active] ? `${optionId}-${active}` : undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
value={open ? query : selected?.name ?? (value ? fallbackLabel ?? '' : '')}
|
||||
placeholder={T.workflowPlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onFocus={show}
|
||||
onClick={() => { if (!open) show() }}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setActive(0)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onBlur?.()
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && (
|
||||
<ul id={listId} role="listbox" aria-label={T.workflow}
|
||||
className="absolute inset-x-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-md border bg-panel py-1 shadow-elev-2">
|
||||
{matches.length ? matches.map((w, i) => (
|
||||
<li
|
||||
key={w.id}
|
||||
id={`${optionId}-${i}`}
|
||||
role="option"
|
||||
aria-selected={w.id === value}
|
||||
data-workflow-option={w.id}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => pick(w)}
|
||||
className={clsx('flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-xs', i === active && 'bg-hover')}
|
||||
>
|
||||
<span className={clsx('min-w-0 flex-1 truncate', w.id === value && 'font-medium')}>{w.name}</span>
|
||||
<span className="chip shrink-0">{workflowStatusText(w)}</span>
|
||||
</li>
|
||||
)) : <li className="px-2.5 py-1.5 text-xs text-faint" role="presentation">{T.workflowNone}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* 定时任务页的纯函数:频率预设和 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,
|
||||
ScheduleWebhookTest, Workflow, WorkflowVersion,
|
||||
} 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存回来时表单怎么接:保存途中人没再动过的项换成刚存下的设置,动过的留着(相对刚存下的设置照样算未保存)。
|
||||
* Webhook 逐项比:途中只改了事件时,交上去的替换地址、新密钥照样换成「已保存」,不让含令牌的完整地址留在框里、
|
||||
* 下次保存再发一遍
|
||||
*/
|
||||
export function rebaseForm(cur: ScheduleForm, sent: ScheduleForm, saved: ScheduleForm): ScheduleForm {
|
||||
if (cur === sent) return saved
|
||||
const pick = <K extends keyof ScheduleForm>(k: K) => (sameJson(cur[k], sent[k]) ? saved[k] : cur[k])
|
||||
const hook = <K extends keyof WebhookForm>(k: K) => (sameJson(cur.webhook[k], sent.webhook[k]) ? saved.webhook[k] : cur.webhook[k])
|
||||
// 地址和「替换」、密钥和它的处理方式各算一组:只换半组会留下「要替换却没填地址」这种拼不上的状态
|
||||
const urlSame = cur.webhook.url === sent.webhook.url && cur.webhook.replaceUrl === sent.webhook.replaceUrl
|
||||
const secretSame = cur.webhook.secret === sent.webhook.secret && cur.webhook.secretMode === sent.webhook.secretMode
|
||||
return {
|
||||
name: pick('name'),
|
||||
workflowId: pick('workflowId'),
|
||||
freq: pick('freq'),
|
||||
timezone: pick('timezone'),
|
||||
runClass: pick('runClass'),
|
||||
inputs: pick('inputs'),
|
||||
enabled: pick('enabled'),
|
||||
misfireGraceS: pick('misfireGraceS'),
|
||||
webhook: {
|
||||
on: hook('on'),
|
||||
format: hook('format'),
|
||||
url: urlSame ? saved.webhook.url : cur.webhook.url,
|
||||
replaceUrl: urlSame ? saved.webhook.replaceUrl : cur.webhook.replaceUrl,
|
||||
secret: secretSame ? saved.webhook.secret : cur.webhook.secret,
|
||||
secretMode: secretSame ? saved.webhook.secretMode : cur.webhook.secretMode,
|
||||
events: hook('events'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端 422 指的那一项在表单里的值,拿来比:人改过那一项就不再摆着那句说明(改回原值时说明又出来)。
|
||||
* 运行类别、输入连带工作流一起算:换了工作流,「没发布」「字段对不上」也就不一定成立了。认不出的项返回 null
|
||||
*/
|
||||
export function serverFieldValue(field: string, f: ScheduleForm): string | null {
|
||||
switch (field) {
|
||||
case 'name': return f.name.trim()
|
||||
case 'workflow_id': return f.workflowId
|
||||
case 'cron': return cronOf(f.freq)
|
||||
case 'timezone': return f.timezone
|
||||
case 'run_class': return JSON.stringify([f.runClass, f.workflowId])
|
||||
case 'input_template': return JSON.stringify([sortedEntries(templateOf(f.inputs)), f.workflowId, f.runClass])
|
||||
case 'misfire_grace_s': return String(f.misfireGraceS)
|
||||
case 'webhook':
|
||||
case 'webhook.url': return JSON.stringify(f.webhook)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输入字段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type InputField = { name: string; required?: boolean; description?: string; default?: unknown }
|
||||
|
||||
/** 工作流(或某个版本)入口节点声明的输入字段 */
|
||||
export function graphFields(graph: WorkflowVersion['graph'] | Workflow['graph'] | undefined): InputField[] {
|
||||
const entry = graph?.nodes?.find((n) => n.type === 'input')
|
||||
return ((entry?.data?.config?.fields ?? []) as InputField[]).filter((f) => f && typeof f.name === 'string' && f.name)
|
||||
}
|
||||
|
||||
const blankDefault = (v: unknown) => v == null || (typeof v === 'string' ? v : JSON.stringify(v)) === ''
|
||||
|
||||
/**
|
||||
* 输入模板对照这次会用到的字段(正式运行是已发布的那一版,探索运行是画布)。照服务端保存时的核对:
|
||||
* unknown 是填了值、但不在这些字段里的(交上去会被拒);missing 是必填、没有默认值、又没填的。
|
||||
* 只有空白的不交,不算。入口没声明字段的工作流什么键都收,两样都不报
|
||||
*/
|
||||
export function inputProblems(inputs: Record<string, string>, fields: readonly InputField[]): { unknown: string[]; missing: string[] } {
|
||||
if (!fields.length) return { unknown: [], missing: [] }
|
||||
const tpl = templateOf(inputs)
|
||||
const names = new Set(fields.map((x) => x.name))
|
||||
return {
|
||||
unknown: Object.keys(tpl).filter((k) => !names.has(k)),
|
||||
missing: fields.filter((x) => x.required && blankDefault(x.default) && !(x.name in tpl)).map((x) => x.name),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 表单的问题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events'
|
||||
|
||||
/** 判表单问题时要的、表单之外的事 */
|
||||
export interface FormContext {
|
||||
/** 选中的工作流的发布版本:null 是没发布;undefined 是还不知道(工作流列表没取回来),不判 */
|
||||
published?: number | null
|
||||
/** 这次会用到的输入字段;还没读到时不判 */
|
||||
fields?: readonly InputField[]
|
||||
/**
|
||||
* 保存时服务端会不会核对工作流、运行类别和输入:新建总会;修改只在改了这三样之一时才会(只改名、停用不查,
|
||||
* 工作流后来撤了发布也能先改名、先停下来)。不给按新建、修改区分
|
||||
*/
|
||||
recheck?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单哪几项还不能保存,值是写在那一项下面的话。saved 是修改中的定时任务(新建时为 null)。
|
||||
* 服务端保存时就会拒的组合(正式运行 + 工作流没发布、必填没填、字段对不上)在这里拦下,不让人点了保存才知道
|
||||
*/
|
||||
export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormContext = {}): 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
|
||||
if (f.workflowId && (ctx.recheck ?? !saved)) {
|
||||
if (f.runClass === 'formal' && ctx.published === null) out.runClass = T.problem.notPublished
|
||||
if (ctx.fields) {
|
||||
const { unknown, missing } = inputProblems(f.inputs, ctx.fields)
|
||||
if (unknown.length) out.inputs = T.problem.inputsUnknown(unknown.join('、'))
|
||||
else if (missing.length) out.inputs = T.problem.inputsMissing(missing.join('、'))
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试消息的结果
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 测试消息的结果写成一句:结局(已送达 / 发送失败)、状态码、服务端的说明各说一遍。服务端的 message 两种写法都接得住:
|
||||
* 只是补充说明(「请到接收端确认已收到」「open.feishu.cn 返回 HTTP 404:…」)时接在结局后面;已经是整句
|
||||
* (「测试消息已送达(HTTP 200)」「推送失败:…」)时不再套一层。说明里写过状态码就不在结局后面再写
|
||||
*/
|
||||
export function webhookTestText(r: Pick<ScheduleWebhookTest, 'ok' | 'http_status'> & { message?: string | null }): string {
|
||||
const head = r.ok ? T.testOk : T.testFailed
|
||||
let detail = (r.message ?? '').trim()
|
||||
if (detail.startsWith(head)) return detail
|
||||
// 失败时开头那半句「推送失败:」「发送失败:」和结局重复
|
||||
if (!r.ok) detail = detail.replace(/^(?:测试消息)?(?:推送|发送)失败[::]\s*/, '')
|
||||
const status = r.http_status != null && !detail.includes(`HTTP ${r.http_status}`) ? r.http_status : null
|
||||
return T.testResult(head, status, detail)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useSyncExternalStore, type MouseEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { CalendarClock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import { StatusPill, confirmDialog, toast } from '../../components/ui'
|
||||
import { formatDateTime, parseServerTime } from '../../lib/format'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types'
|
||||
import { browserTimezone, formatUntil, formatZoned } from './model'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 启用开关
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 启用 / 暂停的开关(role=switch,空格、回车切换)。点了就 PATCH、立刻生效,不等表单保存:
|
||||
* 暂停是「别再到点发起了」,拖到保存时才生效就晚了。可见的字固定写「启用」,开没开看开关本身和 aria-checked
|
||||
*/
|
||||
export function EnableSwitch({ checked, busy, label, onChange }: {
|
||||
checked: boolean; busy?: boolean; label: string; onChange: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
data-schedule-toggle=""
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-1 py-0.5 text-2xs text-dim outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
data-switch-track=""
|
||||
className="relative inline-block h-4 w-7 shrink-0 rounded-full transition-colors"
|
||||
style={{ background: checked ? 'var(--accent-solid)' : 'var(--border-strong)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 h-3 w-3 rounded-full shadow-elev-1 transition-[left]"
|
||||
style={{ left: checked ? 14 : 2, background: 'var(--on-accent)' }}
|
||||
/>
|
||||
</span>
|
||||
<span>{T.enable}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 时间
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按定时任务的时区写一个时刻;relative 时前面加「3 小时后」。悬停写完整时间和时区,本机时区不同时另给本机时间:
|
||||
* 只写一个钟点,人在别的时区打开会把它当本地时间
|
||||
*/
|
||||
export function ZonedTime({ date, tz, now, relative = false, className }: {
|
||||
date: Date; tz: string; now: number; relative?: boolean; className?: string
|
||||
}) {
|
||||
const abs = formatZoned(date, tz, 'short', new Date(now))
|
||||
const title = [T.zoneTitle(formatZoned(date, tz, 'full'), tz),
|
||||
tz !== browserTimezone() ? T.localTitle(formatDateTime(date)) : null].filter(Boolean).join('\n')
|
||||
const rel = relative ? formatUntil(date, now) ?? T.nextOverdue : null
|
||||
return (
|
||||
<time dateTime={date.toISOString()} title={title} className={clsx('tnum', className)}>
|
||||
{rel ? <>{rel}<span className="text-faint"> · {abs}</span></> : abs}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */
|
||||
export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) {
|
||||
if (!schedule.enabled) return <span className="text-faint">{T.nextPaused}</span>
|
||||
const d = parseServerTime(schedule.next_run_at)
|
||||
if (!d) return <span className="text-faint">{T.nextNone}</span>
|
||||
return <ZonedTime date={d} tz={schedule.timezone} now={now} relative className="text-dim" />
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 触发
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 触发结局的颜色:发起了是安静的完成色,跳过是琥珀,发起失败是红 */
|
||||
const FIRE_TONE: Record<ScheduleFireStatus, string> = {
|
||||
started: 'var(--st-done)',
|
||||
skipped_overlap: 'var(--st-waiting)',
|
||||
skipped_misfire: 'var(--st-waiting)',
|
||||
failed_to_start: 'var(--st-failed)',
|
||||
}
|
||||
|
||||
/** 触发结局的小标签。跳过、发起失败铺淡底,悬停写原因(没有原因写这种结局的一般说明) */
|
||||
export function FireStatusChip({ fire }: { fire: ScheduleFire }) {
|
||||
const color = FIRE_TONE[fire.status] ?? 'var(--text-dim)'
|
||||
return (
|
||||
<span
|
||||
className="chip shrink-0"
|
||||
title={fire.reason || T.fireStatusHint[fire.status] || undefined}
|
||||
data-fire-status-chip={fire.status}
|
||||
style={{
|
||||
color,
|
||||
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
|
||||
background: fire.status === 'started' ? undefined : `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{T.fireStatus[fire.status] ?? fire.status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发类型:定时 / 手动 / 补发。服务端把错过的触发都记成 kind=misfire:宽限内补发的那一次照常有结局,没有补发的
|
||||
* 是 skipped_misfire。后者没有「补发」这回事,按「定时」写,结局由旁边的「错过已跳过」说。没有补发有两种原因(超出
|
||||
* 补发宽限;宽限内恢复时积压的那几次不补),悬停不替服务端认定是哪一种,否则会和本条记的原因自相矛盾
|
||||
*/
|
||||
export function FireKindChip({ fire }: { fire: ScheduleFire }) {
|
||||
const missed = fire.kind === 'misfire' && fire.status === 'skipped_misfire'
|
||||
const kind = missed ? 'scheduled' : fire.kind
|
||||
return (
|
||||
<span className="chip shrink-0" title={missed ? T.fireKindMissedHint(!!fire.reason) : T.fireKindHint[kind]} data-fire-kind-chip={fire.kind}>
|
||||
{T.fireKind[kind] ?? kind}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表里的「上次触发」:发起了运行的链到那次运行、写运行现在的状态;跳过、发起失败的写结局 */
|
||||
export function LastFire({ fire, tz, now }: { fire: ScheduleFire | null; tz: string; now: number }) {
|
||||
if (!fire) return <span className="text-faint">{T.neverFired}</span>
|
||||
const d = parseServerTime(fire.fire_at)
|
||||
const when = d ? <ZonedTime date={d} tz={tz} now={now} className="text-faint" /> : null
|
||||
if (fire.run_id) {
|
||||
return (
|
||||
<Link to={`/runs/${encodeURIComponent(fire.run_id)}`} className="inline-flex min-w-0 items-center gap-1.5 hover:underline">
|
||||
{fire.run_status ? <StatusPill status={fire.run_status} plain /> : <FireStatusChip fire={fire} />}
|
||||
{when}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<FireStatusChip fire={fire} />
|
||||
{when}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 列表行和详情页头共用的操作
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 启用 / 暂停:带读到的版本 PATCH,成功交回新的定时任务。版本不符(409)时说清是别处改的、返回 'conflict',
|
||||
* 由调用方重新载入;别的错误弹提示、返回 null。409 的原话是写给表单保存的(「请刷新后再保存」),开关不照搬,
|
||||
* 放进详情
|
||||
*/
|
||||
export async function setEnabled(s: Schedule, enabled: boolean): Promise<Schedule | 'conflict' | null> {
|
||||
try {
|
||||
const next = await api.schedules.update(s.id, { enabled, version: s.version })
|
||||
toast.ok(enabled ? T.enabledOk(s.name) : T.pausedOk(s.name))
|
||||
return next
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
toast.warn(T.toggleConflict(s.name), e.message ? { detail: e.message } : undefined)
|
||||
return 'conflict'
|
||||
}
|
||||
toast.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。返回这次触发(出错时 null),
|
||||
* 调用方拿它更新「上次触发」和触发历史
|
||||
*/
|
||||
export async function runNow(s: Schedule, navigate: (to: string) => void): Promise<ScheduleRunResult | null> {
|
||||
try {
|
||||
const r = await api.schedules.run(s.id)
|
||||
const runId = r.run_id ?? r.fire?.run_id ?? null
|
||||
if (runId) {
|
||||
toast.ok(T.runStarted, { action: { label: T.openRun, onClick: () => navigate(`/runs/${encodeURIComponent(runId)}`) } })
|
||||
} else if (r.fire?.status === 'skipped_overlap' || r.fire?.status === 'skipped_misfire') {
|
||||
toast.warn(r.fire.status === 'skipped_overlap' ? T.runSkipped : T.fireStatus.skipped_misfire,
|
||||
r.fire.reason ? { detail: r.fire.reason } : undefined)
|
||||
} else {
|
||||
toast.error(T.runFailed(r.fire?.reason ?? null))
|
||||
}
|
||||
return r
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除:先用站内对话框确认、写清后果。删掉了返回 true */
|
||||
export async function removeSchedule(s: Schedule): Promise<boolean> {
|
||||
const ok = await confirmDialog({
|
||||
title: T.deleteTitle(s.name), consequences: T.deleteConsequences, confirmLabel: T.deleteConfirm, danger: true,
|
||||
})
|
||||
if (!ok) return false
|
||||
try {
|
||||
await api.schedules.remove(s.id)
|
||||
rememberScheduleNames([], [s.id])
|
||||
toast.ok(T.deleted(s.name))
|
||||
return true
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 老服务端没有定时任务接口:404 且没有机读码(有码的 404 是服务端自己说的「不存在」) */
|
||||
export const isUnsupported = (e: unknown) => e instanceof ApiError && e.status === 404 && !e.code
|
||||
&& /^not found$/i.test(String(e.detail ?? ''))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 记录页的「定时」标记
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 定时任务的名字,给记录页的「定时」标记写悬停提示。模块里记一份:定时任务页读过列表就顺手记下,记录页遇到
|
||||
* 不认识的 id 再取一次列表(一分钟内最多一次)。取不到(老服务端、暂时连不上)不要紧,标记照样显示,只是不写名字
|
||||
*/
|
||||
const names = new Map<string, string>()
|
||||
let fetchedAt = 0
|
||||
let inflight: Promise<void> | null = null
|
||||
let namesVersion = 0
|
||||
const nameListeners = new Set<() => void>()
|
||||
const NAMES_RETRY_MS = 60_000
|
||||
|
||||
const emitNames = () => {
|
||||
namesVersion++
|
||||
nameListeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
/** 记下读到的定时任务名字(gone 里的是刚删掉的) */
|
||||
export function rememberScheduleNames(items: Pick<Schedule, 'id' | 'name'>[], gone: string[] = []) {
|
||||
for (const s of items) names.set(s.id, s.name)
|
||||
for (const id of gone) names.delete(id)
|
||||
emitNames()
|
||||
}
|
||||
|
||||
function ensureNames(ids: string[]) {
|
||||
if (inflight || !ids.some((id) => !names.has(id)) || Date.now() - fetchedAt < NAMES_RETRY_MS) return
|
||||
inflight = api.schedules.list({ timeoutMs: 10_000 })
|
||||
.then((r) => { for (const s of r.items ?? []) names.set(s.id, s.name) })
|
||||
.catch(() => { /* 取不到就不写名字 */ })
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
fetchedAt = Date.now()
|
||||
emitNames()
|
||||
})
|
||||
}
|
||||
|
||||
/** 这些定时任务的名字(id → 名字)。没记过的去取一次,取回来后重渲染 */
|
||||
export function useScheduleNames(ids: (string | null | undefined)[]): ReadonlyMap<string, string> {
|
||||
useSyncExternalStore(
|
||||
(l) => { nameListeners.add(l); return () => { nameListeners.delete(l) } },
|
||||
() => namesVersion,
|
||||
)
|
||||
const key = [...new Set(ids.filter((x): x is string => !!x))].sort().join(',')
|
||||
useEffect(() => { if (key) ensureNames(key.split(',')) }, [key])
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行名字旁的「定时」标记:悬停写是哪个定时任务,点一下去那个定时任务。记录页的整行是打开运行的按钮,
|
||||
* 这里拦住冒泡,只去定时任务、不打开运行。
|
||||
*
|
||||
* 按钮里放不了可聚焦的链接(读屏会把按钮里的内容压平,Tab 也到不了),所以键盘到不了这个标记:读屏改念
|
||||
* 「由定时任务「X」发起」,至少在整行的名字里听到来源。键盘可达的入口要放到运行详情的页头,或者把整行改成
|
||||
* 「打开运行」按钮和这个链接并排,都不在本页的文件里,见交付说明
|
||||
*/
|
||||
export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: string | null }) {
|
||||
const navigate = useNavigate()
|
||||
const go = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/schedules/${encodeURIComponent(scheduleId)}`)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="chip shrink-0 cursor-pointer hover:border-[var(--border-strong)] hover:text-fg"
|
||||
title={T.runTagTitle(name)}
|
||||
data-run-schedule={scheduleId}
|
||||
onClick={go}
|
||||
>
|
||||
<CalendarClock size={10} aria-hidden /> <span aria-hidden>{T.runTag}</span>
|
||||
<span className="sr-only">{T.runTagSr(name)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
schedules: 60, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+1427
-6
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,12 @@
|
||||
"text": "报告按 [[i:字段名]]",
|
||||
"rule": "半角标点",
|
||||
"reason": "[[i:字段名]] 是报告里引用运行输入的标记写法,冒号是语法的一部分,提示用户照原样写"
|
||||
},
|
||||
{
|
||||
"file": "frontend/src/lib/terms.ts",
|
||||
"text": "钉钉",
|
||||
"rule": "钉",
|
||||
"reason": "「钉钉」是 Webhook 通知的产品名(定时任务的消息格式:通用 / 飞书 / 企业微信 / 钉钉),不是「钉住」的意思"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user