数据事件触发(界面):定时页新建时选「定时 / 事件触发」、事件那一节、列表与详情的等待状态、触发历史和运行详情的「由事件触发」

做了什么:
- 新建定时任务(目标是工作流时)先选怎么触发:定时 / 事件触发(TriggerEditor 的 TriggerModePicker);修改时只读地写出触发方式、
  说明建好之后不能改。选了事件触发,频率那一节换成事件(EventSection):三种事件、对象下拉(能选哪些、不能选的原因由新接口
  GET /api/schedules/trigger-options 给,界面照着画、选不了的置灰并写原因)、上传表格的期次(只认新增的一期)、上游工作流的
  只认应用 / 只认来源和触发链上限的说明、去抖、时区;换事件种类时对象和过滤清掉。
- 预览按一次事件渲染输入(对象最近一次的事件,没有时照实说是示例值),不列触发时间;变量按事件种类取(api.schedules.eventVariables),
  插得进 {{ event.* }};请求体带 trigger_kind、trigger,cron 交空串;修改时只交改了的触发条件。model.ts 加 TriggerForm 与
  triggerBody / switchEvent,表单问题里事件触发不查频率、要求选对象;服务端 422 指 trigger 时落到事件那一节。
- 列表、详情:「事件触发」标签,频率处写触发说明;下次运行写在等什么(去抖中收到几次、何时合成一次触发;有事件等待派发;调度
  没开时照实说不会派发)。触发历史:类型「事件」,写「由事件触发:…」、合并了几次、第几层;立即运行按最近一次事件发起的写
  「按事件:…」并标示例值。运行详情页头写「由事件触发:…(定时任务「X」)」(RunDetailView 只多传一个 trigger 属性),立即运行
  按事件发起的写「立即运行定时任务「X」,按事件:…」,不说成由事件触发;应用页印章同样不算。
- 文案进 lib/termsTrigger.ts;SCHEDULE_TEXT 只补触发类型「事件」和「还需处理」里的「触发事件」。立即运行按示例事件的说法
  不再在服务端加「(示例值)」后缀,是不是示例值看 real。

为什么:总方案 §4 要求定时页能建事件触发的任务、列表 / 详情 / 触发历史 / 记录页照实显示;能不能选、为什么由服务端判,界面不放逻辑。

验证:tsc、check-copy;check-schedules 加纯函数一段(表单 ⇄ 请求体)和三段页面(新建的请求体、列表 / 详情 / 触发历史、运行详情
页头,含 390px 浅色),假接口加了触发选项、事件预览、事件变量、事件触发任务的夹具,写请求断言请求体;原有各段照过;check-runs 照过。
This commit is contained in:
ThearchyHelios
2026-10-18 16:11:55 +08:00
parent a1ab3247f9
commit 3f1eeff841
17 changed files with 1010 additions and 74 deletions
+10 -2
View File
@@ -8,8 +8,8 @@
* cron / timezone / input_template 哪一项(ApiError.field)。老服务端没有这些接口,回 404 且没有 code
*/
import type {
Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult,
ScheduleTargetCreate, ScheduleVariable, ScheduleWebhookTest,
Schedule, ScheduleCreate, ScheduleEventKind, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest,
ScheduleRunResult, ScheduleTargetCreate, ScheduleTriggerOptions, ScheduleVariable, ScheduleWebhookTest,
} from '../types'
import { del, get, patch, post, qs, request, type RequestOptions } from './http'
@@ -30,6 +30,14 @@ export const schedules = {
request<SchedulePreview>('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }),
/** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */
variables: (opts?: RequestOptions) => get<ScheduleVariable[]>('/schedules/variables', opts),
/**
* 事件触发任务的模板里能用的变量:schedule.*(上一次计划触发时间除外)加上这种事件的 event.*(docs/design/orchestration-round
* §4)。老服务端不认这两个参数,照旧给 cron 的那一份
*/
eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) =>
get<ScheduleVariable[]>(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts),
/** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */
triggerOptions: (opts?: RequestOptions) => get<ScheduleTriggerOptions>('/schedules/trigger-options', opts),
/** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */
run: (id: string) => post<ScheduleRunResult>(`${at(id)}/run`),
/** 触发历史,新的在前。before 是上一页给的 next_before */
+7 -4
View File
@@ -3985,11 +3985,13 @@ export const SCHEDULE_TEXT = {
openRun: '查看运行',
// ---- 触发
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record<import('../types').ScheduleFireKind, string>,
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发', event: '事件' } as Record<
import('../types').ScheduleFireKind, string>,
fireKindHint: {
scheduled: '到点按计划触发',
manual: '手动「立即运行」',
misfire: '错过触发时间后,在补发宽限内恢复时补发的一次',
event: '由数据事件触发(去抖结束的时刻)',
} as Record<import('../types').ScheduleFireKind, string>,
/**
* 错过、没有补发的(服务端记 kind=misfire、status=skipped_misfire):类型按「定时」写。没有补发的原因有两种(超出
@@ -4225,9 +4227,10 @@ export const SCHEDULE_TEXT = {
},
/** 「还需处理」里各项的叫法 */
problemField: {
name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', runClass: '运行类别', inputs: '输入', url: 'Webhook 地址',
secret: '签名密钥', events: '通知事件',
} as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events', string>,
name: '名称', workflow: '工作流', freq: '频率', trigger: '触发事件', timezone: '时区', runClass: '运行类别', inputs: '输入',
url: 'Webhook 地址', secret: '签名密钥', events: '通知事件',
} as Record<'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events',
string>,
// ---- 相对时间(将来)
until: {
+105
View File
@@ -0,0 +1,105 @@
/**
* 数据事件触发的文案(docs/design/orchestration-round/00-总方案.md §4):定时任务页新建时选「定时 / 事件触发」、事件那一节、
* 列表和详情里的等待状态、触发历史里的事件、运行详情页头的「由事件触发:…」。规矩同 terms.ts 开头的术语表:「运行」不写跑,
* 「服务端」不写后端,全角标点。
*
* 能选什么、为什么不能选、触发说明(「上传表格「X」出了新的一期时触发(去抖 1 分钟)」)、一次事件的说法都由服务端给
* (scheduler/triggers.py),这里只有叫法和句式。事件种类的叫法和服务端 triggers.EVENT_LABEL 逐字对照。
*/
import type { ScheduleEventKind, ScheduleTriggerKind } from '../types'
export const TRIGGER_TEXT = {
// ---- 新建时选触发方式
modeLabel: '怎么触发',
mode: { cron: '定时', event: '事件触发' } as Record<ScheduleTriggerKind, string>,
modeHint: {
cron: '按设定的频率到点发起运行',
event: '数据有变化时发起运行:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化',
} as Record<ScheduleTriggerKind, string>,
/** 修改时:触发方式建好之后不能改 */
modeFixed: (label: string) => `触发方式:${label}。建好之后不能改,要换请新建一个定时任务`,
// ---- 事件那一节
section: '触发事件',
eventLabel: '事件',
event: {
'table.version': '上传表格出了新的一期',
'workflow.finished': '上游工作流运行成功结束',
'source.changed': '数据源有要处理的变化',
} as Record<ScheduleEventKind, string>,
eventHint: {
'table.version': '按期累积的上传表格导入了新的一期并启用时触发;每次上传替换整张表的表格没有这个事件',
'workflow.finished': '选中的工作流有一次正式运行成功结束时触发;输入模板可以引用它的出口字段',
'source.changed': '数据源的探查、剖析新记下「要处理」级的变化时触发;同一次探查或剖析产生的一批只触发一次',
} as Record<ScheduleEventKind, string>,
subject: { 'table.version': '上传表格', 'workflow.finished': '上游工作流', 'source.changed': '数据源' } as Record<
ScheduleEventKind, string>,
subjectPlaceholder: (label: string) => `选择${label}`,
subjectEmpty: {
'table.version': '还没有上传表格',
'workflow.finished': '还没有工作流',
'source.changed': '还没有数据源',
} as Record<ScheduleEventKind, string>,
/** 不能选的对象:名字后面接服务端给的原因 */
subjectNotOk: (name: string, reason: string) => `${name}(${reason})`,
subjectGone: (name: string) => `${name}(已不存在)`,
optionsLoading: '正在读取可选的对象…',
optionsFailed: '无法读取可选的对象',
optionsUnsupported: '服务端暂不支持事件触发,请升级服务端后再使用',
retry: '重试',
periodLabel: '期次',
period: { any: '新增的一期和替换已有的一期都认', new: '只认新增的一期' } as Record<'any' | 'new', string>,
periodHint: '替换已有的一期:重新上传了已经导入过的统计期(例如改正了数),选「只认新增的一期」时不触发',
appLabel: '只认应用',
appAny: '不限应用',
originLabel: '只认来源',
originAny: '不限来源',
filterHint: '只在上游那次正式运行由这个应用、这种来源发起时触发',
debounce: '去抖',
debounceHint: '这段时间内的几次事件合成一次触发,按最新的那一次渲染输入;上一次运行还没结束时仍按重叠策略跳过',
debounceOption: (span: string) => (span ? `${span}内合成一次` : '不去抖,每次事件都触发'),
timezone: '时区',
timezoneHint: '输入模板里的日期变量、事件里的时间按这个时区写',
chainHint: (n: number) => `上游工作流可以一个接一个地触发下游,最多 ${n} 层;串成环的设置保存时会被拒绝`,
// ---- 预览
preview: '按一次事件预览',
previewIdle: '选好事件和对象后在此预览渲染出的输入',
previewLatest: '按这个对象最近一次的事件渲染',
previewExample: '这个对象还没有发生过这种事件,下面是示例值;立即运行时用到事件变量的输入不会发起',
previewInputs: '渲染出的输入',
previewNoInputs: '没有填写输入,按工作流的默认值',
// ---- 列表、详情的等待状态
chip: '事件触发',
chipTitle: '由数据事件触发,不按时间',
nextEvent: '等待事件',
waiting: (n: number, when: string) => `去抖中:已收到 ${n} 次事件,将在 ${when} 合成一次触发`,
pending: (n: number) => `有 ${n} 条事件等待派发`,
pendingOff: (n: number) => `有 ${n} 条事件在等待,但定时任务调度未开启,不会派发`,
schedulerOff: '定时任务调度未开启:事件会留着,不会派发',
// ---- 触发历史
fireEvent: (text: string) => `由事件触发:${text}`,
/** 立即运行按示例事件发起的那一次:不是事件触发的,写按的是哪一次事件 */
fireSample: (text: string) => `按事件:${text}`,
merged: (n: number) => `合并了 ${n} 次事件`,
depth: (n: number) => `触发链第 ${n} 层`,
sample: '立即运行,按最近一次事件',
sampleExample: '立即运行,事件为示例值',
// ---- 运行详情页头
runSource: (text: string, name?: string | null) => (name ? `由事件触发:${text}(定时任务「${name}」)` : `由事件触发:${text}`),
runSourceGone: (text: string, name?: string | null) =>
(name ? `由事件触发:${text}(定时任务「${name}」,已删除)` : `由事件触发:${text}(定时任务已删除)`),
/** 立即运行按示例事件发起的运行:写成「来自定时任务」那一类,后面接按的是哪一次事件 */
runSourceSample: (text: string, name?: string | null) =>
(name ? `立即运行定时任务「${name}」,按事件:${text}` : `立即运行定时任务,按事件:${text}`),
// ---- 表单的问题
problemSubject: (label: string) => `请选择${label}`,
problemField: '触发事件',
/** 事件触发时「错过触发时间」那一项的说明 */
misfireHint: '调度停过(服务停机、调度未开启)时,事件在宽限内派发照常发起,超过则跳过',
inputsHint: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值',
}
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
</time>
<span>·</span>
{/* 定时任务发起的:写成到那个定时任务的链接(键盘可达;记录页的「定时」标记只能鼠标点) */}
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} /> : (
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} trigger={run.origin?.trigger} /> : (
<span title={run.started_by ? undefined : '发起时未设置署名'}>
{run.started_by || '未署名'} 发起
</span>
+21 -1
View File
@@ -5,7 +5,8 @@ import { api } from '../../api/client'
import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui'
import { formatDateTime, parseServerTime, shortId } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { Schedule, ScheduleFire, ScheduleFireCompare, ScheduleFireEvent } from '../../types'
import { comparePath, type CompareVia } from '../runs/compare/entry'
import { maintenanceJobPath, maintenanceStepText } from './model'
import { FireKindChip, FireStatusChip, ZonedTime, targetKindOf } from './parts'
@@ -170,6 +171,7 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
{maintenance && (jobs.length > 0 || skipped.length > 0) && (
<MaintenanceFire sourceId={schedule.target_id ?? ''} jobs={jobs} skipped={skipped} />
)}
{f.event && <EventFire event={f.event} />}
{reason && (
<div className="mt-1 text-2xs leading-relaxed text-dim">
<span className="text-faint">{T.reason}:</span>{reason}
@@ -190,6 +192,24 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
)
}
/**
* 事件触发的那一次(docs/design/orchestration-round/00-总方案.md §4):由什么事件触发(服务端写好的那一句)、去抖合并了几次、
* 在触发链上第几层;立即运行时按示例事件发起的照实标出来(示例值的另说)
*/
function EventFire({ event }: { event: ScheduleFireEvent }) {
const notes = [
event.merged > 1 ? TT.merged(event.merged) : null,
event.depth > 1 ? TT.depth(event.depth) : null,
event.sample ? (event.real ? TT.sample : TT.sampleExample) : null,
].filter(Boolean)
return (
<div className="mt-1 text-2xs leading-relaxed text-dim" data-fire-event={event.event}>
{event.sample ? TT.fireSample(event.text) : TT.fireEvent(event.text)}
{notes.length > 0 && <span className="text-faint">({notes.join(';')})</span>}
</div>
)
}
/**
* 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析
* 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果;
+104 -36
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '../../lib/errors'
import { parseServerTime } from '../../lib/format'
import { useLeaveGuard } from '../../lib/leave'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import { useCatalog, useCatalogListState } from '../../store/catalog'
import type { Schedule, SchedulePreview, ScheduleRunClass, ScheduleVariable, Workflow } from '../../types'
import type { Schedule, ScheduleEventKind, SchedulePreview, ScheduleRunClass, ScheduleTrigger, ScheduleVariable, Workflow } from '../../types'
import {
GRACE_OPTIONS, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem, graceLabel, graphFields, patchBody,
rebaseForm, serverFieldValue, templateOf, type Freq, type FormField, type InputField, type ScheduleForm,
rebaseForm, serverFieldValue, templateOf, triggerBody, type Freq, type FormField, type InputField, type ScheduleForm,
} from './model'
import { isUnsupported } from './parts'
import { EventSection, TriggerModePicker } from './TriggerEditor'
import { WebhookFields } from './WebhookFields'
import { WorkflowPicker } from './WorkflowPicker'
@@ -23,6 +25,9 @@ import { WorkflowPicker } from './WorkflowPicker'
* 新建、修改定时任务的表单。宽屏左边是表单、右边是接下来 5 次触发的预览(跟着滚动停在顶上);窄屏预览排在
* 频率下面,改了频率当场看得到。
*
* 新建时先选「定时 / 事件触发」(docs/design/orchestration-round/00-总方案.md §4):事件触发的没有频率,那一节换成事件
* (TriggerEditor 的 EventSection),预览按一次事件渲染输入(不列触发时间),变量多出 {{ event.* }}。触发方式建好之后不能改。
*
* schedule 为 null 是新建。修改时只交改了的字段,带上 version:详情页页头的开关改过之后,version 是开关交回的
* 新版本,表单照常保存;别人在这期间改过就 409,表单不动,给「重新载入」。
*/
@@ -54,17 +59,20 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
} | null>(null)
const workflow = workflows.find((w) => w.id === form.workflowId)
const cron = cronOf(form.freq)
const event = form.triggerKind === 'event'
const cron = event ? '' : cronOf(form.freq)
const template = useMemo(() => templateOf(form.inputs), [form.inputs])
const preview = usePreview(cron, form.timezone, template, form.workflowId)
const trigger = useMemo(() => (event ? triggerBody(form.trigger) : null), [event, form.trigger])
const preview = usePreview(cron, form.timezone, template, form.workflowId, trigger)
const fieldsOf = useInputFields(workflow, form.runClass)
// 修改时工作流列表还没取回来:输入字段是「正在读取」,不是「请先选择工作流」
const fields: FieldsState = !workflow && form.workflowId && workflowsState !== 'ok' ? { status: 'loading' } : fieldsOf
const variables = useVariables()
const variables = useVariables(event ? form.trigger.event : null)
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 recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes
|| 'trigger' in changes))
const problems = formProblems(form, base, {
published: workflow ? workflow.published_version ?? null : undefined,
fields: fields.status === 'ok' ? fields.fields : undefined,
@@ -75,6 +83,7 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
/** 预览判定非法、并指明是这一项时,服务端的说明 */
const previewSays = (field: string) => (preview.status === 'invalid' && preview.field === field ? preview.message : undefined)
const previewBlocks = previewField === 'cron' || previewField === 'timezone' || previewField === 'input_template'
|| previewField === 'trigger' || previewField === 'debounce_s'
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
@@ -178,16 +187,24 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
<WorkflowField form={form} workflows={workflows} state={workflowsState} workflow={workflow} base={base}
error={shown('workflow') ?? serverError(['workflow_id'])}
onPick={pickWorkflow} onBlur={() => touch('workflow')} />
<TriggerModePicker value={form.triggerKind} fixed={!!base} onChange={(triggerKind) => set({ triggerKind })} />
</section>
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
{event ? (
<EventSection value={form.trigger} onChange={(t) => set({ trigger: t })} timezone={form.timezone}
setTimezone={(timezone) => set({ timezone })} savedName={base?.trigger?.subject_name ?? null}
error={previewSays('trigger') ?? serverError(['trigger', 'trigger_kind'])}
debounceError={previewSays('debounce_s') ?? serverError(['debounce_s'])} />
) : (
<FreqPicker freq={form.freq} timezone={form.timezone} 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} />
<PreviewBox preview={preview} event={event} />
</div>
</aside>
@@ -196,13 +213,13 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
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}
preview={preview} recheck={recheck} event={event}
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}>
<Field label={T.misfire} htmlFor="schedule-misfire" hint={event ? TT.misfireHint : 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'}
@@ -291,13 +308,18 @@ const PREVIEW_DEBOUNCE_MS = 400
/**
* 接下来 5 次触发和渲染出的输入(服务端算,前端不自己解析 cron)。改动中保留上一次的结果并标「正在计算」;
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题。
*
* trigger 不为空是事件触发:交触发条件、不交 cron,服务端按一次示例事件渲染输入(没有触发时间);对象还没选时不问
*/
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string): PreviewState {
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string,
trigger: ScheduleTrigger | null = null): PreviewState {
const [state, setState] = useState<PreviewState>({ status: 'idle' })
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId])
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId,
trigger])
useEffect(() => {
if (!cron || !timezone) {
const subject = trigger ? trigger.source_id ?? trigger.workflow_id : null
if (trigger ? !subject || !timezone : !cron || !timezone) {
setState({ status: 'idle' })
return
}
@@ -306,7 +328,7 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
const ctrl = new AbortController()
const timer = setTimeout(() => {
api.schedules.preview({
cron, timezone, n: 5,
...(trigger ? { trigger_kind: 'event' as const, trigger, cron: '' } : { cron, n: 5 }), timezone,
...(Object.keys(template).length ? { input_template: template } : {}),
...(workflowId ? { workflow_id: workflowId } : {}),
}, { signal: ctrl.signal })
@@ -324,22 +346,23 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
clearTimeout(timer)
ctrl.abort()
}
// key 已经把四项都编进去了
// key 已经把五项都编进去了
}, [key])
return state
}
export function PreviewBox({ preview }: { preview: PreviewState }) {
export function PreviewBox({ preview, event = false }: { preview: PreviewState; event?: boolean }) {
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}>
data-schedule-preview="" data-status={preview.status} data-preview-mode={event ? 'event' : 'cron'}>
<div className="mb-2 flex items-center gap-2">
<h2 id="schedule-preview-title" className="text-xs font-semibold">{T.preview}</h2>
<h2 id="schedule-preview-title" className="text-xs font-semibold">{event ? TT.preview : T.preview}</h2>
{preview.status === 'loading' && <Spinner size={11} />}
</div>
{data && (
{data && event && <EventPreview data={data} loading={preview.status === 'loading'} />}
{data && !event && (
<>
{data.description_text && <div className="mb-2 text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
{data.next?.length ? (
@@ -362,7 +385,7 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
</>
)}
{!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 === 'idle' && <p className="text-xs text-faint">{event ? TT.previewIdle : 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="">
@@ -373,6 +396,40 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
)
}
/**
* 事件触发的预览:触发说明、按哪一次事件渲染(对象最近一次的事件,没有时是示例值、照实说),渲染出的输入逐项列出。
* 没有触发时间可列:事件什么时候来不由时间定
*/
function EventPreview({ data, loading }: { data: SchedulePreview; loading: boolean }) {
const inputs = Object.entries(data.inputs ?? {})
return (
<div className={clsx('space-y-2', loading && 'opacity-60')}>
{data.description_text && <div className="text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
{data.event && (
<div className="text-2xs leading-relaxed" data-preview-event={data.event.real ? 'real' : 'example'}>
<div className="text-fg">{data.event.text}</div>
<div className={data.event.real ? 'text-faint' : 'text-[var(--warn)]'}>
{data.event.real ? TT.previewLatest : TT.previewExample}
</div>
</div>
)}
<div>
<div className="text-2xs text-faint">{TT.previewInputs}</div>
{inputs.length ? (
<dl className="mt-0.5 space-y-0.5 text-2xs">
{inputs.map(([k, v]) => (
<div key={k} className="flex min-w-0 gap-1.5" data-preview-input={k}>
<dt className="mono shrink-0 text-dim">{k}</dt>
<dd className="mono min-w-0 break-all text-fg">{v === '' ? T.renderedEmpty : v}</dd>
</div>
))}
</dl>
) : <p className="text-2xs text-faint">{TT.previewNoInputs}</p>}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// 运行类别
// ---------------------------------------------------------------------------
@@ -477,25 +534,34 @@ function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunCla
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)
/**
* 输入模板里能用的变量:服务端给,各处共用一份(按 cron 的一份,事件触发按事件种类各一份);取不到时为空表,变量按钮
* 照样在,只是说没有可用的
*/
const variablesOnce = new Map<string, Promise<ScheduleVariable[]>>()
function useVariables(event: ScheduleEventKind | null = null): ScheduleVariable[] | null {
const [vars, setVars] = useState<{ key: string; list: ScheduleVariable[] } | null>(null)
const key = event ?? 'cron'
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([]) })
let once = variablesOnce.get(key)
if (!once) {
once = (event ? api.schedules.eventVariables(event) : api.schedules.variables())
.then((v) => (Array.isArray(v) ? v : []), (e) => {
variablesOnce.delete(key)
throw e
})
variablesOnce.set(key, once)
}
once.then((v) => { if (alive) setVars({ key, list: v }) }, () => { if (alive) setVars({ key, list: [] }) })
return () => { alive = false }
}, [])
return vars
}, [key]) // eslint-disable-line react-hooks/exhaustive-deps
return vars?.key === key ? vars.list : null
}
const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, error, onChange }: {
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, event = false, error, onChange }: {
workflow: Workflow | undefined
fields: FieldsState
inputs: Record<string, string>
@@ -503,6 +569,8 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
preview: PreviewState
/** 保存时服务端会不会核对输入(新建,或者改了工作流、运行类别、输入):会的话必填没填、字段对不上都要先处理 */
recheck: boolean
/** 事件触发:模板变量另按事件计算(说明不同) */
event?: boolean
error?: string
onChange: (inputs: Record<string, string>) => void
}) {
@@ -621,7 +689,7 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
<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>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{event ? TT.inputsHint : 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}
@@ -9,8 +9,8 @@ import type { Schedule } from '../../types'
import { ClassChip } from '../runs/parts'
import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail'
import {
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
targetText,
EnableSwitch, EventChip, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled,
targetKindOf, targetText,
} from './parts'
/**
@@ -173,6 +173,7 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
{workflow
? <ClassChip runClass={s.run_class} />
: <span className="chip shrink-0" data-schedule-target-kind={kind}>{T.targetKind[kind]}</span>}
{s.trigger_kind === 'event' && <EventChip />}
{!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={`${target} · ${s.description_text}`} data-schedule-desc="">
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { api } from '../../api/client'
import { Field, Notice, Skeleton, useRadioGroup } from '../../components/ui'
import { errorMessage } from '../../lib/errors'
import { timezoneOptions } from '../../lib/freq'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { ScheduleEventKind, ScheduleTriggerKind, ScheduleTriggerOptions } from '../../types'
import { DEBOUNCE_OPTIONS, EVENT_KINDS, TRIGGER_KINDS, graceLabel, switchEvent, type TriggerForm } from './model'
import { isUnsupported } from './parts'
/**
* 数据事件触发的表单部件(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」(TriggerModePicker),
* 选了事件触发时用 EventSection 替换频率那一节——事件种类、对象、过滤、去抖、时区。
*
* 能选哪些对象、不能选的为什么不能选,都是服务端给的(GET /api/schedules/trigger-options):这里只照着画,不自己判「是不是
* 按期累积」「发没发布」;会不会串成环保存时由服务端核对,422 的说明落到这一节(error)。
*/
/** 新建时选触发方式;修改时只读地写出是哪一种(触发方式建好之后不能改) */
export function TriggerModePicker({ value, onChange, fixed }: {
value: ScheduleTriggerKind
onChange: (v: ScheduleTriggerKind) => void
/** 修改中的定时任务:只读 */
fixed?: boolean
}) {
const radio = useRadioGroup(TRIGGER_KINDS, value, onChange)
if (fixed) {
return <p className="text-2xs leading-relaxed text-faint" data-trigger-mode-fixed={value}>{TT.modeFixed(TT.mode[value])}</p>
}
return (
<div data-trigger-modes="">
<div className="label" id="schedule-trigger-mode">{TT.modeLabel}</div>
<div role="radiogroup" aria-labelledby="schedule-trigger-mode" className="flex flex-wrap gap-1">
{TRIGGER_KINDS.map((k) => (
<button key={k} type="button" {...radio(k)} onClick={() => onChange(k)} data-trigger-mode={k}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
value === k ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{TT.mode[k]}
</button>
))}
</div>
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.modeHint[value]}</p>
</div>
)
}
type OptionsState =
| { status: 'loading' }
| { status: 'ok'; data: ScheduleTriggerOptions }
| { status: 'error'; error: unknown; retry: () => void }
/** 可选的对象:各处共用一份,失败时清掉、下次再取 */
let optionsOnce: Promise<ScheduleTriggerOptions> | null = null
function useTriggerOptions(): OptionsState {
const [state, setState] = useState<OptionsState>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let alive = true
setState({ status: 'loading' })
optionsOnce ??= api.schedules.triggerOptions().catch((e) => {
optionsOnce = null
throw e
})
optionsOnce.then(
(data) => { if (alive) setState({ status: 'ok', data }) },
(error) => { if (alive) setState({ status: 'error', error, retry: () => setAttempt((n) => n + 1) }) },
)
return () => { alive = false }
}, [attempt])
return state
}
export function EventSection({ value, onChange, timezone, setTimezone, savedName, error, debounceError }: {
value: TriggerForm
onChange: (t: TriggerForm) => void
timezone: string
setTimezone: (tz: string) => void
/** 修改时对象的名字(不在可选列表里时写它,标「已不存在」) */
savedName?: string | null
/** 这一节的问题(还没选对象)或保存被拒时服务端的说明(对象不合用、会循环触发) */
error?: string
debounceError?: string
}) {
const options = useTriggerOptions()
const radio = useRadioGroup(EVENT_KINDS, value.event, (event) => onChange(switchEvent(value, event)))
const zones = useMemo(() => timezoneOptions(timezone), [timezone])
const set = (patch: Partial<TriggerForm>) => onChange({ ...value, ...patch })
const kind = value.event
const label = TT.subject[kind]
const entry = options.status === 'ok' ? options.data.events.find((e) => e.event === kind) : undefined
const objects = entry?.objects ?? []
const listed = objects.some((o) => o.id === value.subjectId)
let picker
if (options.status === 'loading') picker = <Skeleton rows={1} height={30} />
else if (options.status === 'error') {
picker = (
<Notice tone="err">
{isUnsupported(options.error) ? TT.optionsUnsupported : `${TT.optionsFailed}:${errorMessage(options.error)}`}
{!isUnsupported(options.error) && (
<button type="button" className="btn btn-xs ml-2" onClick={options.retry}>{TT.retry}</button>
)}
</Notice>
)
} else {
picker = (
<select id="schedule-trigger-subject" className="field" value={value.subjectId} data-trigger-subject={kind}
aria-invalid={!!error || undefined}
aria-describedby={error ? 'schedule-trigger-subject-error' : undefined}
onChange={(e) => set({ subjectId: e.target.value })}>
<option value="">{objects.length ? TT.subjectPlaceholder(label) : TT.subjectEmpty[kind]}</option>
{value.subjectId && !listed && <option value={value.subjectId}>{TT.subjectGone(savedName || value.subjectId)}</option>}
{objects.map((o) => (
<option key={o.id} value={o.id} disabled={!o.ok && o.id !== value.subjectId} data-trigger-object-ok={String(o.ok)}>
{o.ok || !o.reason ? o.name : TT.subjectNotOk(o.name, o.reason)}
</option>
))}
</select>
)
}
return (
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-trigger-title" data-trigger-section="">
<h2 id="schedule-trigger-title" className="text-xs font-semibold">{TT.section}</h2>
<div>
<div className="label" id="schedule-trigger-event">{TT.eventLabel}</div>
<div role="radiogroup" aria-labelledby="schedule-trigger-event" className="flex flex-wrap gap-1">
{EVENT_KINDS.map((e: ScheduleEventKind) => (
<button key={e} type="button" {...radio(e)} onClick={() => onChange(switchEvent(value, e))} data-trigger-event={e}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
kind === e ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{TT.event[e]}
</button>
))}
</div>
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.eventHint[kind]}</p>
</div>
<Field label={label} htmlFor="schedule-trigger-subject" required>
{picker}
</Field>
{error && (
<div id="schedule-trigger-subject-error" className="-mt-1.5 text-2xs leading-relaxed text-[var(--err)]" role="alert"
data-trigger-error="">
{error}
</div>
)}
{kind === 'table.version' && (
<Field label={TT.periodLabel} htmlFor="schedule-trigger-period" hint={TT.periodHint}>
<select id="schedule-trigger-period" className="field w-auto max-w-full" value={value.period} data-trigger-period=""
aria-describedby="schedule-trigger-period-hint"
onChange={(e) => set({ period: e.target.value === 'new' ? 'new' : 'any' })}>
<option value="any">{TT.period.any}</option>
<option value="new">{TT.period.new}</option>
</select>
</Field>
)}
{kind === 'workflow.finished' && options.status === 'ok' && (
<div className="space-y-1">
<div className="flex flex-wrap gap-3">
<Field label={TT.appLabel} htmlFor="schedule-trigger-app" className="min-w-[12rem] flex-1">
<select id="schedule-trigger-app" className="field" value={value.appId} data-trigger-app=""
onChange={(e) => set({ appId: e.target.value })}>
<option value="">{TT.appAny}</option>
{value.appId && !options.data.apps.some((a) => a.id === value.appId) && (
<option value={value.appId}>{TT.subjectGone(value.appId)}</option>
)}
{options.data.apps.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</Field>
<Field label={TT.originLabel} htmlFor="schedule-trigger-origin" className="min-w-[12rem] flex-1">
<select id="schedule-trigger-origin" className="field" value={value.originKind} data-trigger-origin=""
onChange={(e) => set({ originKind: e.target.value })}>
<option value="">{TT.originAny}</option>
{options.data.origins.map((o) => <option key={o.kind} value={o.kind}>{o.label}</option>)}
</select>
</Field>
</div>
<p className="text-2xs leading-relaxed text-faint">{TT.filterHint}</p>
<p className="text-2xs leading-relaxed text-faint" data-trigger-chain="">{TT.chainHint(options.data.max_depth)}</p>
</div>
)}
<Field label={TT.debounce} htmlFor="schedule-trigger-debounce" hint={TT.debounceHint} error={debounceError}>
<select id="schedule-trigger-debounce" className="field w-auto max-w-full" value={value.debounceS} data-trigger-debounce=""
aria-invalid={!!debounceError || undefined}
aria-describedby={debounceError ? 'schedule-trigger-debounce-error' : 'schedule-trigger-debounce-hint'}
onChange={(e) => set({ debounceS: Number(e.target.value) })}>
{[...new Set([...DEBOUNCE_OPTIONS, value.debounceS])].sort((a, b) => a - b).map((s) => (
<option key={s} value={s}>{TT.debounceOption(s ? graceLabel(s) : '')}</option>
))}
</select>
</Field>
<Field label={TT.timezone} htmlFor="schedule-trigger-timezone" hint={TT.timezoneHint}>
<select id="schedule-trigger-timezone" className="field" value={timezone} data-timezone=""
aria-describedby="schedule-trigger-timezone-hint" onChange={(e) => setTimezone(e.target.value)}>
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
</select>
</Field>
</section>
)
}
+87 -10
View File
@@ -4,9 +4,11 @@
* scripts/check-schedules.mjs 直接借 vite 转译后调用它们。
*/
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type {
Schedule, ScheduleCreate, ScheduleEvent, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate,
ScheduleTargetKind, ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion,
Schedule, ScheduleCreate, ScheduleEvent, ScheduleEventKind, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass,
ScheduleTargetCreate, ScheduleTargetKind, ScheduleTrigger, ScheduleTriggerKind, ScheduleWebhookFormat, ScheduleWebhookIn,
ScheduleWebhookTest, Workflow, WorkflowVersion,
} from '../../types'
export * from '../../lib/freq'
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq'
@@ -31,9 +33,63 @@ export interface WebhookForm {
events: ScheduleEvent[]
}
/**
* 事件触发的设置(docs/design/orchestration-round/00-总方案.md §4)。对象按事件种类:表格、数据源、上游工作流都记在 subjectId,
* 交上去时按种类写成 source_id / workflow_id。过滤只在对应的事件种类上交:期次给上传表格,应用、来源给上游工作流
*/
export interface TriggerForm {
event: ScheduleEventKind
subjectId: string
period: 'any' | 'new'
appId: string
originKind: string
debounceS: number
}
export const EVENT_KINDS: readonly ScheduleEventKind[] = ['table.version', 'workflow.finished', 'source.changed']
export const TRIGGER_KINDS: readonly ScheduleTriggerKind[] = ['cron', 'event']
/** 去抖的选项(秒):不去抖、1 分钟(缺省)、5 分钟、15 分钟、1 小时 */
export const DEBOUNCE_OPTIONS: readonly number[] = [0, 60, 300, 900, 3600]
export const DEFAULT_DEBOUNCE_S = 60
export function triggerFormOf(t: ScheduleTrigger | null | undefined): TriggerForm {
const event = t?.event && EVENT_KINDS.includes(t.event) ? t.event : 'table.version'
return {
event,
subjectId: (event === 'workflow.finished' ? t?.workflow_id : t?.source_id) ?? '',
period: t?.period === 'new' ? 'new' : 'any',
appId: t?.app_id ?? '',
originKind: t?.origin_kind ?? '',
debounceS: typeof t?.debounce_s === 'number' ? t.debounce_s : DEFAULT_DEBOUNCE_S,
}
}
/** 交上去的触发条件:对象按种类写键,过滤只带对这种事件有意义、又选了的 */
export function triggerBody(t: TriggerForm): ScheduleTrigger {
const out: ScheduleTrigger = { event: t.event, debounce_s: t.debounceS }
if (t.event === 'workflow.finished') {
out.workflow_id = t.subjectId
if (t.appId) out.app_id = t.appId
if (t.originKind) out.origin_kind = t.originKind
} else {
out.source_id = t.subjectId
if (t.event === 'table.version' && t.period === 'new') out.period = 'new'
}
return out
}
/** 换了事件种类:对象、过滤都不再适用,清掉;去抖留着 */
export function switchEvent(t: TriggerForm, event: ScheduleEventKind): TriggerForm {
if (t.event === event) return t
return { event, subjectId: '', period: 'any', appId: '', originKind: '', debounceS: t.debounceS }
}
export interface ScheduleForm {
name: string
workflowId: string
/** 触发方式:只在新建时选,修改时跟着读到的定时任务(建好之后不能改) */
triggerKind: ScheduleTriggerKind
trigger: TriggerForm
freq: Freq
timezone: string
runClass: ScheduleRunClass
@@ -49,15 +105,19 @@ export interface ScheduleForm {
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,
name: '', workflowId: '', triggerKind: 'cron', trigger: triggerFormOf(null), 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] },
}
}
const event = s.trigger_kind === 'event'
return {
name: s.name,
workflowId: s.workflow_id,
freq: freqOf(s.cron),
triggerKind: event ? 'event' : 'cron',
trigger: triggerFormOf(event ? s.trigger : null),
// 事件触发的任务没有 cron(存空串):频率留缺省,不交也不比
freq: event ? { ...DEFAULT_FREQ } : freqOf(s.cron),
timezone: s.timezone,
runClass: s.run_class,
inputs: { ...(s.input_template ?? {}) },
@@ -79,10 +139,13 @@ const sortEvents = (events: ScheduleEvent[]) => SCHEDULE_EVENTS.filter((e) => ev
export function createBody(f: ScheduleForm): ScheduleCreate {
const w = f.webhook
const event = f.triggerKind === 'event'
return {
name: f.name.trim(),
workflow_id: f.workflowId,
cron: cronOf(f.freq),
// 事件触发:不写 cron(交空串),带触发方式和触发条件;cron 任务的请求体和以前一字不差
...(event ? { trigger_kind: 'event' as const, trigger: triggerBody(f.trigger) } : {}),
cron: event ? '' : cronOf(f.freq),
timezone: f.timezone,
run_class: f.runClass,
input_template: templateOf(f.inputs),
@@ -109,7 +172,9 @@ export function patchBody(f: ScheduleForm, initial: ScheduleForm, saved: Schedul
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.triggerKind === 'event') {
if (!sameJson(triggerBody(f.trigger), triggerBody(initial.trigger))) out.trigger = triggerBody(f.trigger)
} else 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)))) {
@@ -157,6 +222,8 @@ export function rebaseForm(cur: ScheduleForm, sent: ScheduleForm, saved: Schedul
return {
name: pick('name'),
workflowId: pick('workflowId'),
triggerKind: pick('triggerKind'),
trigger: pick('trigger'),
freq: pick('freq'),
timezone: pick('timezone'),
runClass: pick('runClass'),
@@ -279,6 +346,10 @@ export function serverFieldValue(field: string, f: ScheduleForm): string | null
case 'name': return f.name.trim()
case 'workflow_id': return f.workflowId
case 'cron': return cronOf(f.freq)
// 触发条件连带目标一起算:查环时换了目标,「会循环触发」也就不一定成立了
case 'trigger':
case 'debounce_s': return JSON.stringify([triggerBody(f.trigger), f.workflowId])
case 'trigger_kind': return f.triggerKind
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])
@@ -322,7 +393,8 @@ export function inputProblems(inputs: Record<string, string>, fields: readonly I
// 表单的问题
// ---------------------------------------------------------------------------
export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events'
export type FormField = 'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret'
| 'events'
/** 判表单问题时要的、表单之外的事 */
export interface FormContext {
@@ -345,8 +417,13 @@ export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormC
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.triggerKind === 'event') {
// 事件触发没有频率:对象要选上(能不能选、会不会成环由服务端判,保存时 422 落到这一项)
if (!f.trigger.subjectId) out.trigger = TT.problemSubject(TT.subject[f.trigger.event])
} else {
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
+49 -6
View File
@@ -6,6 +6,7 @@ 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 { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types'
import { browserTimezone, formatUntil, formatZoned, maintenanceSummary } from './model'
@@ -71,14 +72,44 @@ export function ZonedTime({ date, tz, now, relative = false, className }: {
)
}
/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */
/**
* 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜。事件触发的任务没有「到点」:写它现在在等什么——去抖中(几次事件、
* 什么时候合成一次触发)、有事件等待派发(调度没开时说不会派发)、等待事件(服务端 trigger_state)
*/
export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) {
if (!schedule.enabled) return <span className="text-faint">{T.nextPaused}</span>
if (schedule.trigger_kind === 'event') return <EventWait schedule={schedule} now={now} />
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" />
}
function EventWait({ schedule, now }: { schedule: Schedule; now: number }) {
const st = schedule.trigger_state
const due = parseServerTime(st?.due_at ?? null)
if (st && st.waiting > 0 && due) {
const when = formatZoned(due, schedule.timezone, 'short', new Date(now))
return <span className="text-dim" data-schedule-event-wait="waiting">{TT.waiting(st.waiting, when)}</span>
}
if (st && st.events > 0) {
return (
<span className={st.scheduler_on ? 'text-dim' : 'text-[var(--warn)]'} data-schedule-event-wait="pending">
{st.scheduler_on ? TT.pending(st.events) : TT.pendingOff(st.events)}
</span>
)
}
return (
<span className="text-faint" data-schedule-event-wait="idle" title={st && !st.scheduler_on ? TT.schedulerOff : undefined}>
{TT.nextEvent}
</span>
)
}
/** 列表、详情页头里「事件触发」的小标签:和运行类别的标签并排 */
export function EventChip() {
return <span className="chip shrink-0" title={TT.chipTitle} data-schedule-trigger-kind="event">{TT.chip}</span>
}
// -------------------------------------------------------------------------
// 触发
// -------------------------------------------------------------------------
@@ -349,13 +380,24 @@ export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: s
*
* 前面带和「定时」标记一样的日历图标:页头这一行都是灰字,不带记号看不出这一段能点。页头窄时这一行换行,链接至少留
* 几个字宽(以前这一行不换行,只有链接能缩,窄窗口里被挤成 0 宽,Tab 停在一个看不见的链接上),再长截断,全名在悬停里。
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样。
*
* 由数据事件触发的(运行来源里的 trigger,发起时写进、在封存范围内):整句写「由事件触发:<事件>(定时任务「X」)」,
* 事件的说法是服务端发起时写下的那一句,不在这里拼
*/
export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: string; startedBy?: string | null }) {
export function RunScheduleSource({ scheduleId, startedBy, trigger }: {
scheduleId: string; startedBy?: string | null
/** 运行来源里的 trigger(服务端 outbox.EventFire.origin);不是事件触发的为空 */
trigger?: { text?: unknown; sample?: unknown } | null
}) {
const names = useScheduleNames([scheduleId])
const recorded = startedBy?.startsWith(T.actorPrefix) ? startedBy.slice(T.actorPrefix.length).trim() : null
const auto = !startedBy || recorded != null
const name = names.get(scheduleId) || recorded || null
const eventText = typeof trigger?.text === 'string' && trigger.text ? trigger.text : null
const sample = !!eventText && trigger?.sample === true
const said = (gone: boolean) => (sample ? TT.runSourceSample(eventText, name) : gone ? TT.runSourceGone(eventText!, name)
: TT.runSource(eventText!, name))
const body = (text: string) => (
<>
<CalendarClock size={10} aria-hidden className="shrink-0" />
@@ -365,16 +407,17 @@ export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: strin
const source = scheduleGone(scheduleId) ? (
<span title={T.runSourceGoneTitle} data-run-schedule-gone={scheduleId}
className="inline-flex min-w-[7em] max-w-full items-center gap-1">
{body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
{body(eventText ? said(true) : auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
</span>
) : (
<Link
to={`/schedules/${encodeURIComponent(scheduleId)}`}
title={T.runSourceTitle(name)}
title={eventText ? said(false) : T.runSourceTitle(name)}
data-run-schedule-link={scheduleId}
data-run-schedule-event={eventText ? '' : undefined}
className="inline-flex min-w-[7em] max-w-full items-center gap-1 rounded-sm text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
{body(auto ? T.runSource(name) : T.runSourceManual(name))}
{body(eventText ? said(false) : auto ? T.runSource(name) : T.runSourceManual(name))}
</Link>
)
if (auto || !startedBy) return source
+99 -2
View File
@@ -3855,8 +3855,8 @@ export type ScheduleRunClass = 'formal' | 'exploratory'
export type ScheduleWebhookFormat = 'generic' | 'feishu' | 'wecom' | 'dingtalk'
/** 订阅的事件:运行结束、运行失败、停在人工审批上(只在真有待审批时发) */
export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval'
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发 */
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire'
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发、由数据事件触发(trigger_kind=event 的任务) */
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire' | 'event'
/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */
export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start'
/**
@@ -4049,6 +4049,103 @@ export interface ScheduleRunResult {
run_id: string | null
}
// ---------------------------------------------------------------------------
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4;服务端 scheduler/triggers.py、outbox.py):一个
// trigger_kind=event 的定时任务,目标只能是工作流。同名接口会合并,这里只往上面几个类型上补字段
// ---------------------------------------------------------------------------
/** 触发方式:按 cron 到点、由数据事件触发。老服务端不给,按 cron 看 */
export type ScheduleTriggerKind = 'cron' | 'event'
/** 事件种类:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化 */
export type ScheduleEventKind = 'table.version' | 'workflow.finished' | 'source.changed'
/** 触发条件(服务端规范化过的那一份)。对象键按事件种类:表格、数据源写 source_id,上游工作流写 workflow_id */
export interface ScheduleTrigger {
event: ScheduleEventKind
source_id?: string
workflow_id?: string
/** 只认新增的一期(不写即新增、替换都认),只给 table.version */
period?: 'new' | 'any'
/** 只认某个应用发起的、某种来源的运行,只给 workflow.finished */
app_id?: string
origin_kind?: string
/** 去抖:同一个任务这么多秒内的几次事件合成一次 */
debounce_s: number
/** 保存时记下的对象名(对象删掉之后界面写它);读出来才有,交上去不用带 */
subject_name?: string
}
/** 事件触发任务现在有几条事件在等:没派发的(调度关着时一直留着)、在去抖窗口里的、窗口结束的时刻、调度开没开 */
export interface ScheduleTriggerState {
events: number
waiting: number
due_at: string | null
scheduler_on: boolean
}
export interface Schedule {
trigger_kind?: ScheduleTriggerKind
/** 事件触发时的触发条件;cron 任务为 null */
trigger?: ScheduleTrigger | null
/** 给人看的触发说明(事件触发时同 description_text);cron 任务为 null */
trigger_text?: string | null
trigger_state?: ScheduleTriggerState | null
}
/** 事件触发的那一次触发带的事件说明(服务端 outbox.EventFire.brief,不含出口字段的值) */
export interface ScheduleFireEvent {
event: ScheduleEventKind
subject_id: string
subject_name: string
/** 一句说法:「上传表格「门店销售」出了新的一期(2026-09)」 */
text: string
/** 去抖合并了几次事件 */
merged: number
first_at: string | null
last_at: string | null
/** 这次运行在事件触发链上是第几层 */
depth: number
/** 立即运行时按示例事件发起的(不是真来了一次事件);real 为假时是示例值 */
sample: boolean
real: boolean
}
export interface ScheduleFire {
/** 事件触发的那几次的事件说明;别的为 null,老服务端不给 */
event?: ScheduleFireEvent | null
}
export interface ScheduleCreate {
/** 不写即 cron(字段和以前一字不差) */
trigger_kind?: ScheduleTriggerKind
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
}
export interface SchedulePreviewRequest {
trigger_kind?: ScheduleTriggerKind
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
}
export interface SchedulePreview {
/** 事件触发时:按哪一次事件渲染的(real 为假时是示例值,对象还没发生过这种事件) */
event?: { text: string; real: boolean } | null
}
/** GET /api/schedules/trigger-options:每种事件能选的对象(ok 为假时不能选,reason 写为什么,由服务端判)、来源、应用 */
export interface ScheduleTriggerOptions {
events: {
event: ScheduleEventKind
label: string
subject_label: string
objects: { id: string; name: string; ok: boolean; reason: string | null }[]
}[]
origins: { kind: string; label: string }[]
apps: { id: string; name: string }[]
default_debounce_s: number
max_debounce_s: number
max_depth: number
}
/** GET /api/schedules/{id}/fires 的一页,新的在前;next_before 是下一页的游标,没有更多时为 null */
export interface ScheduleFirePage {
items: ScheduleFire[]