mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
数据事件触发(界面):定时页新建时选「定时 / 事件触发」、事件那一节、列表与详情的等待状态、触发历史和运行详情的「由事件触发」
做了什么:
- 新建定时任务(目标是工作流时)先选怎么触发:定时 / 事件触发(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:
@@ -530,8 +530,8 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di
|
||||
"""触发方式:cron 的 trigger、trigger_text、trigger_state 为 null;事件触发的给规范化后的触发条件、说法、在等的事件。"""
|
||||
if not _event_kind(r):
|
||||
return {"trigger_kind": "cron", "trigger": None, "trigger_text": None, "trigger_state": None}
|
||||
# subject_name 是保存时记下的对象名(对象删掉之后界面写它);带回来时不认(triggers.normalize)
|
||||
stored = dict(r.trigger or {}) if isinstance(r.trigger, dict) else {}
|
||||
stored.pop("subject_name", None)
|
||||
return {"trigger_kind": "event", "trigger": stored, "trigger_text": _trigger_text(r, trigger_names),
|
||||
"trigger_state": states.get(r.id)}
|
||||
|
||||
|
||||
@@ -290,9 +290,10 @@ async def _from_text(session: AsyncSession, source: Source) -> str:
|
||||
n = await session.scalar(select(func.count(Run.id)).where(
|
||||
Run.schedule_id == run.schedule_id, Run.created_at <= run.created_at))
|
||||
name = schedule.name if schedule is not None else "已删除的定时任务"
|
||||
# 由数据事件触发的(scheduler/outbox:运行来源里记着 trigger)照实写是什么事件
|
||||
# 由数据事件触发的(scheduler/outbox:运行来源里记着 trigger)照实写是什么事件;立即运行按示例事件发起的不算
|
||||
trigger = (run.origin or {}).get("trigger") if isinstance(run.origin, dict) else None
|
||||
lead = f"由事件触发:{trigger['text']}," if isinstance(trigger, dict) and trigger.get("text") else ""
|
||||
event = isinstance(trigger, dict) and trigger.get("text") and not trigger.get("sample")
|
||||
lead = f"由事件触发:{trigger['text']}," if event else ""
|
||||
return f"{lead}定时任务「{name}」第 {int(n or 1)} 次"
|
||||
return "正式运行"
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- webhook.py:通知推送(通用 / 飞书 / 企业微信 / 钉钉)
|
||||
- auto_compare.py:运行成功结束后和同一定时任务上一次成功的运行自动对比,结果记在触发记录上,通知里只带计数摘要和
|
||||
对比页链接
|
||||
- triggers.py、outbox.py:数据事件触发(trigger_kind=event,docs/design/orchestration-round/00-总方案.md §4)——触发条件的
|
||||
规则和说法、防环;待派发事件的写入(发生处)与每一拍的派发、去抖
|
||||
|
||||
服务启动时(main.py 的 lifespan)只在 scheduler_enabled() 时调 start,关闭时总是调 stop——stop 要能在没 start
|
||||
过时安全返回。start 自己也看一次开关:拷来的数据目录(检查脚本的 --stacks、验收副本)不管从哪条路径起服务,
|
||||
|
||||
@@ -148,15 +148,16 @@ class EventFire:
|
||||
return {"kind": "event", "event": self.event, "subject_id": self.subject_id, "subject_name": self.subject_name,
|
||||
"event_id": self.event_ids[-1] if self.event_ids else None, "event_ids": self.event_ids[-MERGED_KEEP:],
|
||||
"merged": len(self.event_ids), "depth": self.depth, "chain": self.chain[-CHAIN_KEEP:],
|
||||
"text": self.text, "sample": self.sample}
|
||||
"text": self.text, "sample": self.sample, "real": self.real}
|
||||
|
||||
|
||||
def sample_fire(sample: triggers.Sample, tz: str) -> EventFire:
|
||||
"""立即运行:按示例事件发起(触发链从这里起算,第 1 层)。"""
|
||||
"""立即运行:按示例事件发起(触发链从这里起算,第 1 层)。是不是示例值记在 real 上,说法不另加后缀:界面、印章按
|
||||
sample / real 自己说「立即运行,按最近一次事件」「事件为示例值」。"""
|
||||
text = triggers.event_text(sample.event, sample.subject_name, sample.payload)
|
||||
return EventFire(event=sample.event, subject_id=sample.subject_id, subject_name=sample.subject_name,
|
||||
values=triggers.values(sample.event, sample.payload, tz, outputs=sample.outputs),
|
||||
text=text if sample.real else f"{text}(示例值)", sample=True, real=sample.real,
|
||||
text=text, sample=True, real=sample.real,
|
||||
chain=[sample.payload["run_id"]] if sample.event == "workflow.finished" and sample.payload.get(
|
||||
"run_id") else [])
|
||||
|
||||
|
||||
@@ -124,7 +124,9 @@ async def test_create_table_version_schedule(client):
|
||||
assert r.status_code == 201, r.text
|
||||
s = r.json()
|
||||
assert (s["trigger_kind"], s["cron"], s["next_run_at"], s["target_kind"]) == ("event", "", None, "workflow")
|
||||
assert s["trigger"] == {"event": "table.version", "source_id": table, "period": "new", "debounce_s": 60}
|
||||
assert {k: v for k, v in s["trigger"].items() if k != "subject_name"} == {
|
||||
"event": "table.version", "source_id": table, "period": "new", "debounce_s": 60}
|
||||
assert s["trigger"]["subject_name"].startswith("门店销售-") # 保存时记下的对象名
|
||||
assert s["trigger_text"].startswith("上传表格「门店销售-") and "(只认新增的一期;去抖 1 分钟)" in s["trigger_text"]
|
||||
assert s["description_text"] == s["trigger_text"]
|
||||
assert s["trigger_state"] == {"events": 0, "waiting": 0, "due_at": None, "scheduler_on": False}
|
||||
@@ -282,7 +284,7 @@ async def test_preview_and_run_now_with_a_sample_event(client):
|
||||
"workflow_id": b})
|
||||
assert r.status_code == 200, r.text
|
||||
p = r.json()
|
||||
assert p["next"] == [] and p["event"]["real"] is False and "示例值" in p["event"]["text"]
|
||||
assert p["next"] == [] and p["event"]["real"] is False and p["event"]["text"].startswith(f"工作流「{a_name}」")
|
||||
s = (await client.post("/api/schedules", json=body(b, trigger, input_template=template))).json()
|
||||
early = (await client.post(f"/api/schedules/{s['id']}/run")).json()
|
||||
assert early["run_id"] is None and early["fire"]["status"] == "failed_to_start"
|
||||
@@ -303,6 +305,13 @@ async def test_preview_and_run_now_with_a_sample_event(client):
|
||||
done = await settle(run["run_id"])
|
||||
assert done.input == {"basis": f"日报完成 · {a_name}"}
|
||||
assert done.origin["trigger"]["sample"] is True and done.origin["trigger"]["event"] == "workflow.finished"
|
||||
assert done.origin["trigger"]["real"] is True and done.origin["trigger"]["chain"] == [up.id]
|
||||
# 应用页印章:立即运行按示例事件发起的不说「由事件触发」
|
||||
from app.apps import resolve
|
||||
|
||||
async with SessionLocal() as session:
|
||||
said = await resolve._from_text(session, resolve.Source(id="v", kind="latest_sealed", state="ok", run=done))
|
||||
assert said.startswith("定时任务「下游汇总」第 ")
|
||||
# 触发历史:事件的说明照写
|
||||
[newest, *_] = (await client.get(f"/api/schedules/{s['id']}/fires")).json()["items"]
|
||||
assert newest["event"]["text"].startswith(f"工作流「{a_name}」的正式运行成功结束(#{up.id[:6]})")
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值',
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析
|
||||
* 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[]
|
||||
|
||||
+298
-2
@@ -13,6 +13,12 @@
|
||||
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
|
||||
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
|
||||
//
|
||||
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」,事件触发的没有频率、换成事件那一节
|
||||
// (事件种类、对象、期次 / 应用 / 来源、去抖、时区),不能选的对象照服务端给的原因标出来、选不了;换事件种类时对象清掉;变量
|
||||
// 按事件种类取({{ event.* }});预览按一次事件渲染输入、不列触发时间;请求体带 trigger_kind、trigger、cron 交空串。列表、
|
||||
// 详情写触发说明和在等的事件(调度没开时照实说不会派发),修改时只读地写出触发方式、只交改了的触发条件;触发历史写「由事件
|
||||
// 触发:…」和合并了几次、第几层、立即运行的示例;运行详情页头写「由事件触发:…(定时任务「X」)」。
|
||||
//
|
||||
// 浏览器的本机时区故意设成 America/Los_Angeles,和定时任务的时区(Asia/Shanghai、America/New_York)都不同:
|
||||
// 两者相同时「按定时任务时区写」和「按本机写」在页面上看不出区别,时间相关的断言一律比对按定时任务时区写出的原文。
|
||||
//
|
||||
@@ -305,6 +311,48 @@ await section('纯函数:服务端会拒的组合、保存回来怎么接、
|
||||
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
|
||||
})
|
||||
|
||||
await section('纯函数:事件触发的表单 ⇄ 请求体', async () => {
|
||||
const m = await import(await load('/src/pages/schedules/model.ts'))
|
||||
const fresh = m.formOf(null, 'Asia/Shanghai')
|
||||
check('新建缺省按 cron 触发,事件那一份缺省是上传表格、去抖 1 分钟', fresh.triggerKind === 'cron'
|
||||
&& fresh.trigger.event === 'table.version' && fresh.trigger.debounceS === 60 && fresh.trigger.subjectId === '')
|
||||
check('cron 任务的请求体一字不差:不带触发方式、触发条件', !('trigger_kind' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' }))
|
||||
&& !('trigger' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' })))
|
||||
const ev = { ...fresh, name: ' 门店月报 ', workflowId: 'wf-weekly', triggerKind: 'event',
|
||||
trigger: { ...fresh.trigger, subjectId: 'ds-sales', period: 'new', appId: 'app-x', originKind: 'hook', debounceS: 300 },
|
||||
inputs: { date: '{{ event.period }}' } }
|
||||
const body = m.createBody(ev)
|
||||
check('事件触发的请求体:trigger_kind、对象按种类写 source_id、只带这种事件的过滤、cron 交空串',
|
||||
body.trigger_kind === 'event' && JSON.stringify(body.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300,
|
||||
source_id: 'ds-sales', period: 'new' }) && body.cron === '' && body.name === '门店月报'
|
||||
&& JSON.stringify(body.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
|
||||
const wf = m.triggerBody({ event: 'workflow.finished', subjectId: 'wf-daily', period: 'new', appId: 'app-board', originKind: '', debounceS: 0 })
|
||||
check('上游工作流:写 workflow_id,带应用、不带期次和空的来源', JSON.stringify(wf) === JSON.stringify({ event: 'workflow.finished',
|
||||
debounce_s: 0, workflow_id: 'wf-daily', app_id: 'app-board' }), JSON.stringify(wf))
|
||||
const switched = m.switchEvent(ev.trigger, 'source.changed')
|
||||
check('换事件种类:对象、过滤清掉,去抖留着', switched.subjectId === '' && switched.period === 'any' && switched.appId === ''
|
||||
&& switched.originKind === '' && switched.debounceS === 300 && m.switchEvent(ev.trigger, 'table.version') === ev.trigger)
|
||||
check('问题:事件触发没选对象时说要选什么,不查频率', m.formProblems({ ...ev, trigger: switched, freq: { ...m.DEFAULT_FREQ, preset: 'weekly',
|
||||
weekdays: [] } }, null).trigger === '请选择数据源' && !m.formProblems({ ...ev, freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] } },
|
||||
null).freq)
|
||||
// 夹具里的定时任务在后面才定义(这一段在顶层先跑),这里另写一份
|
||||
const saved = { id: 's-event', name: '门店月报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '', timezone: 'Asia/Shanghai',
|
||||
run_class: 'formal', input_template: { date: '{{ event.period }}' }, enabled: true, overlap: 'skip', misfire_grace_s: 3600,
|
||||
webhook: null, next_run_at: null, last_fire: null, description_text: '', created_by: null, created_at: '', updated_at: '', version: 2,
|
||||
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' } }
|
||||
const base = m.formOf(saved)
|
||||
check('读出来的表单:触发方式、对象、期次、去抖', base.triggerKind === 'event' && base.trigger.subjectId === 'ds-sales'
|
||||
&& base.trigger.period === 'new' && base.trigger.debounceS === 300)
|
||||
check('什么都没改:不发请求(不因为 cron 是空串算成改了频率)', m.patchBody(base, base, saved) === null)
|
||||
const patch = m.patchBody({ ...base, trigger: { ...base.trigger, debounceS: 900 } }, base, saved)
|
||||
check('只改去抖:只交触发条件(整份)', JSON.stringify(patch) === JSON.stringify({ trigger: { event: 'table.version', debounce_s: 900,
|
||||
source_id: 'ds-sales', period: 'new' } }), JSON.stringify(patch))
|
||||
check('服务端指 trigger 时:换了对象或目标就不再摆着那句', m.serverFieldValue('trigger', base)
|
||||
!== m.serverFieldValue('trigger', { ...base, trigger: { ...base.trigger, subjectId: 'ds-other' } })
|
||||
&& m.serverFieldValue('trigger', base) !== m.serverFieldValue('trigger', { ...base, workflowId: 'wf-stock' })
|
||||
&& m.serverFieldValue('trigger', base) === m.serverFieldValue('trigger', { ...base, name: '别的名字' }))
|
||||
})
|
||||
|
||||
/** 对比页写的默认基准来历(定时任务发起的):只挑比本次先发起、成功结束并封存的运行,服务端 api/compare.py 的 pick_base */
|
||||
const REASON_SCHEDULE = '默认基准:同一定时任务上一次成功的运行'
|
||||
|
||||
@@ -559,6 +607,93 @@ const SUITES = [
|
||||
{ id: 'suite-stock', name: '库存问法', description: '', archived: false },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- 数据事件触发的夹具(orchestration-round §4)
|
||||
|
||||
/** 能选的对象(服务端 GET /api/schedules/trigger-options 的形状):不能选的带原因,界面照它画、不自己判 */
|
||||
const ROSTER_REASON = '每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积'
|
||||
const TRIGGER_OPTIONS = {
|
||||
events: [
|
||||
{ event: 'table.version', label: '上传表格出了新的一期', subject_label: '上传表格', objects: [
|
||||
{ id: 'ds-sales', name: '门店销售', ok: true, reason: null },
|
||||
{ id: 'ds-roster', name: '门店名单', ok: false, reason: ROSTER_REASON },
|
||||
] },
|
||||
{ event: 'workflow.finished', label: '上游工作流运行成功结束', subject_label: '工作流', objects: [
|
||||
{ id: 'wf-daily', name: '门店日报', ok: true, reason: null },
|
||||
{ id: 'wf-stock', name: '库存日报', ok: false, reason: '还没有发布版本:只有正式运行成功结束才算事件' },
|
||||
] },
|
||||
{ event: 'source.changed', label: '数据源有要处理的变化', subject_label: '数据源', objects: [
|
||||
{ id: 'ds-shop', name: '门店库', ok: true, reason: null },
|
||||
] },
|
||||
],
|
||||
origins: [{ kind: 'launcher', label: '手动正式运行' }, { kind: 'schedule', label: '定时任务' }, { kind: 'hook', label: '入站钩子' }],
|
||||
apps: [{ id: 'app-board', name: '门店看板' }],
|
||||
default_debounce_s: 60, max_debounce_s: 86400, max_depth: 5,
|
||||
}
|
||||
/** 事件触发的变量清单(服务端按事件种类给:去掉上一次计划触发时间,接上 event.*) */
|
||||
const EVENT_VARIABLES = {
|
||||
'table.version': [
|
||||
VARIABLES[0],
|
||||
{ name: 'event.table', label: '上传表格的名字', example: '门店销售', note: '' },
|
||||
{ name: 'event.period', label: '这一期(整月写 YYYY-MM,整周写 YYYY-Www,单日写 YYYY-MM-DD)', example: '2026-09', note: '' },
|
||||
{ name: 'event.period_end', label: '这一期的最后一天(YYYY-MM-DD)', example: '2026-09-30', note: '' },
|
||||
],
|
||||
'workflow.finished': [
|
||||
VARIABLES[0],
|
||||
{ name: 'event.run_id', label: '上游那次运行的编号', example: 'run…', note: '' },
|
||||
{ name: 'event.outputs.<字段名>', label: '上游运行的出口字段(写 event.outputs.<字段名>)', example: '{…}', note: '' },
|
||||
],
|
||||
'source.changed': [VARIABLES[0], { name: 'event.counts.new', label: '变化的条数', example: '{…}', note: '' }],
|
||||
}
|
||||
const SALES_EVENT = '上传表格「门店销售」出了新的一期(2026-09)'
|
||||
const SALES_TRIGGER = '上传表格「门店销售」出了新的一期时触发(只认新增的一期;去抖 5 分钟)'
|
||||
const SHOP_TRIGGER = '数据源「门店库」出现要处理的变化时触发(去抖 1 分钟)'
|
||||
const EVENT_BRIEF = { event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, merged: 2,
|
||||
first_at: iso(NOW - 25 * H), last_at: iso(NOW - 24 * H - 10 * 60e3), depth: 1, sample: false, real: true }
|
||||
/** 事件触发的定时任务:去抖中(2 次事件在等),触发历史有事件、立即运行的示例、上一次没结束跳过的 */
|
||||
const S_EVENT = {
|
||||
...S1, id: 's-event', name: '门店月报 · 新一期', cron: '', next_run_at: null, webhook: null,
|
||||
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' },
|
||||
trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER, input_template: { date: '{{ event.period_end }}' },
|
||||
trigger_state: { events: 0, waiting: 2, due_at: iso(NOW + 4 * 60e3), scheduler_on: true },
|
||||
last_fire: fire('fe1', { kind: 'event', event: EVENT_BRIEF }), version: 2,
|
||||
}
|
||||
/** 调度没开:3 条事件留着、不会派发 */
|
||||
const S_EVENT_OFF = {
|
||||
...S_EVENT, id: 's-event-off', name: '变化处理', trigger: { event: 'source.changed', source_id: 'ds-shop', debounce_s: 60, subject_name: '门店库' },
|
||||
trigger_text: SHOP_TRIGGER, description_text: SHOP_TRIGGER, trigger_state: { events: 3, waiting: 0, due_at: null, scheduler_on: false },
|
||||
last_fire: null, version: 1,
|
||||
}
|
||||
function eventFires() {
|
||||
const none = { run_id: null, run_status: null }
|
||||
return [
|
||||
fire('fe1', { kind: 'event', event: EVENT_BRIEF }),
|
||||
fire('fe2', { ...none, kind: 'manual', status: 'failed_to_start', fire_at: iso(NOW - 30 * H), created_at: iso(NOW - 30 * H),
|
||||
reason: '还没有发生过这种事件,输入模板里的 {{ event.* }} 没有可用的值,这次立即运行没有发起',
|
||||
event: { ...EVENT_BRIEF, merged: 0, sample: true, real: false } }),
|
||||
fire('fe3', { ...none, kind: 'event', status: 'skipped_overlap', fire_at: iso(NOW - 48 * H), created_at: iso(NOW - 48 * H),
|
||||
reason: '上一次运行 #a1b2c3 仍在进行中,本次触发已跳过', event: { ...EVENT_BRIEF, merged: 1, depth: 2 } }),
|
||||
]
|
||||
}
|
||||
/** 由事件触发的那次运行:来源里记着 trigger(服务端发起时写下的那一句) */
|
||||
const RUN_EVENT = { ...RUNS[0], id: 'run-event-1', schedule_id: 's-event', started_by: '定时任务:门店月报 · 新一期',
|
||||
origin_kind: 'schedule', origin: { kind: 'schedule', schedule_id: 's-event', fire_kind: 'event',
|
||||
trigger: { kind: 'event', event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, depth: 1 } } }
|
||||
|
||||
/** 有人署名点了立即运行:按最近一次事件发起(sample),不是事件触发的 */
|
||||
const RUN_SAMPLE = { ...RUN_EVENT, id: 'run-event-2', started_by: '检查员甲',
|
||||
origin: { ...RUN_EVENT.origin, fire_kind: 'manual', trigger: { ...RUN_EVENT.origin.trigger, sample: true, real: true } } }
|
||||
|
||||
/** 事件触发的预览:按一次事件渲染输入,不列触发时间;不合用的对象回 422(field=trigger) */
|
||||
function eventPreviewReply(body) {
|
||||
const t = body?.trigger ?? {}
|
||||
if (t.source_id === 'ds-roster') return { status: 422, json: { detail: '「门店名单」不是按期累积的上传表格', field: 'trigger' } }
|
||||
const tpl = body?.input_template ?? {}
|
||||
const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v)
|
||||
.replace(/\{\{\s*event\.period\s*\}\}/g, '2026-09').replace(/\{\{\s*event\.period_end\s*\}\}/g, '2026-09-30')]))
|
||||
const desc = `上传表格「门店销售」出了新的一期时触发(${t.period === 'new' ? '只认新增的一期;' : ''}去抖 ${t.debounce_s === 300 ? '5 分钟' : '1 分钟'})`
|
||||
return { json: { description_text: desc, next: [], inputs, event: { text: SALES_EVENT, real: true } } }
|
||||
}
|
||||
|
||||
function freshState(patch = {}) {
|
||||
return {
|
||||
schedules: [structuredClone(S1), structuredClone(S2)],
|
||||
@@ -598,7 +733,7 @@ const WEBHOOK_TEST_OK = { ok: true, http_status: 200, message: '请到接收端
|
||||
/** 照约定改假状态:PATCH 核对版本、按地址省略 / 空串改 Webhook */
|
||||
function applyPatch(s, body) {
|
||||
const out = { ...s }
|
||||
for (const k of ['name', 'workflow_id', 'cron', 'timezone', 'run_class', 'input_template', 'enabled', 'misfire_grace_s']) {
|
||||
for (const k of ['name', 'workflow_id', 'cron', 'timezone', 'run_class', 'input_template', 'enabled', 'misfire_grace_s', 'trigger']) {
|
||||
if (k in body) out[k] = body[k]
|
||||
}
|
||||
if ('webhook' in body) {
|
||||
@@ -614,7 +749,7 @@ function applyPatch(s, body) {
|
||||
}
|
||||
out.version = s.version + 1
|
||||
out.updated_at = iso(Date.now())
|
||||
if (!out.enabled) out.next_run_at = null
|
||||
if (!out.enabled || s.trigger_kind === 'event') out.next_run_at = null
|
||||
else if (!s.enabled) out.next_run_at = iso(NOW + 3 * H + 5 * 60e3)
|
||||
return out
|
||||
}
|
||||
@@ -635,7 +770,19 @@ async function reply(state, method, path, url, body) {
|
||||
}
|
||||
const m = path.match(/^\/schedules\/([^/]+)(\/.*)?$/)
|
||||
if (method === 'GET' && path === '/schedules') return { json: { items: state.schedules } }
|
||||
if (method === 'POST' && path === '/schedules/preview' && body?.trigger_kind === 'event') return eventPreviewReply(body)
|
||||
if (method === 'POST' && path === '/schedules/preview') return previewReply(body, state)
|
||||
if (method === 'GET' && path === '/schedules/trigger-options') return { json: TRIGGER_OPTIONS }
|
||||
if (method === 'GET' && path === '/schedules/variables' && url.searchParams.get('trigger_kind') === 'event') {
|
||||
return { json: EVENT_VARIABLES[url.searchParams.get('event')] ?? [] }
|
||||
}
|
||||
if (method === 'POST' && path === '/schedules' && body?.trigger_kind === 'event') {
|
||||
const s = { ...S_EVENT, ...body, id: 's-new-event', workflow_name: WORKFLOWS.find((w) => w.id === body.workflow_id)?.name ?? '',
|
||||
trigger: { ...body.trigger, subject_name: '门店销售' }, trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER,
|
||||
trigger_state: { events: 0, waiting: 0, due_at: null, scheduler_on: true }, last_fire: null, version: 1, webhook: null }
|
||||
state.schedules = [s, ...state.schedules]
|
||||
return { status: 201, json: s }
|
||||
}
|
||||
if (method === 'GET' && path === '/schedules/variables') return { json: VARIABLES }
|
||||
if (method === 'POST' && path === '/schedules' && body?.target_kind && body.target_kind !== 'workflow') {
|
||||
const suite = SUITES.find((x) => x.id === body.target_id)
|
||||
@@ -2116,6 +2263,155 @@ await section('记录页:定时标记', async () => {
|
||||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 数据事件触发(orchestration-round §4)
|
||||
|
||||
await section('事件触发:新建的请求体', async () => {
|
||||
const { page, sent, errors } = await open('/schedules/new')
|
||||
await page.locator('[data-schedule-form]').waitFor()
|
||||
const picker = page.locator('[data-workflow-picker] input')
|
||||
await picker.click()
|
||||
await picker.fill('周报')
|
||||
await page.locator('[data-workflow-option="wf-weekly"]').click()
|
||||
await page.locator('[data-field="name"]').fill('门店月报 · 新一期')
|
||||
check('新建时有「定时 / 事件触发」两种,缺省定时', await page.locator('[data-trigger-mode]').count() === 2
|
||||
&& await page.locator('[data-trigger-mode="cron"]').getAttribute('aria-checked') === 'true'
|
||||
&& await page.locator('[data-freq-preset]').count() > 0)
|
||||
await page.locator('[data-trigger-mode="event"]').click()
|
||||
await page.locator('[data-trigger-section]').waitFor()
|
||||
check('选了事件触发:频率那一节换成事件,三种事件、缺省上传表格', await page.locator('[data-freq-preset]').count() === 0
|
||||
&& await page.locator('[data-trigger-event]').count() === 3
|
||||
&& await page.locator('[data-trigger-event="table.version"]').getAttribute('aria-checked') === 'true')
|
||||
const subject = page.locator('[data-trigger-subject="table.version"]')
|
||||
await subject.waitFor()
|
||||
const roster = subject.locator('option[value="ds-roster"]')
|
||||
check('不能选的对象照服务端的原因标出来、选不了', await roster.isDisabled()
|
||||
&& (await roster.innerText()).includes('没有「新的一期」'), await roster.innerText())
|
||||
check('还没选对象:保存按钮禁用,说还需处理触发事件', await page.locator('[data-schedule-save]').isDisabled()
|
||||
&& (await text(page, '[data-schedule-hint]')).includes('触发事件'), await text(page, '[data-schedule-hint]').catch(() => ''))
|
||||
await subject.selectOption('ds-sales')
|
||||
// 换事件种类:对象清掉,换成上游工作流的对象和过滤
|
||||
await page.locator('[data-trigger-event="workflow.finished"]').click()
|
||||
await page.locator('[data-trigger-subject="workflow.finished"]').waitFor()
|
||||
check('换成上游工作流:对象清掉,多出只认应用、只认来源和触发链的说明',
|
||||
await page.locator('[data-trigger-subject="workflow.finished"]').inputValue() === ''
|
||||
&& await page.locator('[data-trigger-app]').count() === 1 && await page.locator('[data-trigger-origin]').count() === 1
|
||||
&& (await text(page, '[data-trigger-chain]')).includes('最多 5 层'), await text(page, '[data-trigger-chain]').catch(() => ''))
|
||||
await page.locator('[data-trigger-event="table.version"]').click()
|
||||
await page.locator('[data-trigger-subject="table.version"]').selectOption('ds-sales')
|
||||
await page.locator('[data-trigger-period]').selectOption('new')
|
||||
await page.locator('[data-trigger-debounce]').selectOption('300')
|
||||
await page.locator('[data-timezone]').selectOption('Asia/Shanghai')
|
||||
check('事件触发时「错过触发时间」说的是派发晚了', (await text(page, '[data-schedule-form]')).includes('事件在宽限内派发照常发起'))
|
||||
// 变量按事件种类取:event.period 在、上一次计划触发时间不在
|
||||
await page.locator('[data-input-field="date"]').waitFor()
|
||||
await page.locator('[data-input-vars="date"]').click()
|
||||
await page.locator('[data-input-field="date"] [data-var="event.period"]').waitFor()
|
||||
check('变量清单按事件种类取:有 event.period', await page.locator('[data-input-field="date"] [data-var="event.table"]').count() === 1)
|
||||
check('……请求带 trigger_kind=event、event=table.version', sent.some((x) => x.key === 'GET /schedules/variables'
|
||||
&& x.search.includes('trigger_kind=event') && x.search.includes('event=table.version')), sent.filter((x) => x.key.includes('variables'))
|
||||
.map((x) => x.search).join(' | '))
|
||||
await page.locator('[data-input-field="date"] [data-var="event.period"]').click()
|
||||
check('点变量插入 {{ event.period }}', await page.locator('[data-input-field="date"] input').inputValue() === '{{ event.period }}')
|
||||
const preview = await until(async () => (await page.locator('[data-schedule-preview][data-status="ok"][data-preview-mode="event"]').count())
|
||||
&& (await page.locator('[data-preview-input="date"]').innerText().catch(() => '')).includes('2026-09'))
|
||||
check('预览按一次事件渲染输入,不列触发时间', !!preview && await page.locator('[data-preview-time]').count() === 0
|
||||
&& (await text(page, '[data-preview-event="real"]')).includes(SALES_EVENT)
|
||||
&& (await text(page, '[data-preview-desc]')).includes('只认新增的一期'), await text(page, '[data-schedule-preview]').catch(() => ''))
|
||||
const pv = writes(sent, /^POST \/schedules\/preview$/).at(-1)?.body
|
||||
check('预览的请求体:trigger_kind、触发条件、cron 空串', pv?.trigger_kind === 'event' && pv?.cron === '' && pv?.trigger?.source_id === 'ds-sales'
|
||||
&& !('n' in (pv ?? {})), JSON.stringify(pv))
|
||||
await page.locator('[data-schedule-save]').click()
|
||||
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
|
||||
const body = writes(sent, /^POST \/schedules$/)[0]?.body
|
||||
check('POST 请求体:trigger_kind=event、触发条件、cron 空串', body?.trigger_kind === 'event' && body?.cron === ''
|
||||
&& JSON.stringify(body?.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300, source_id: 'ds-sales', period: 'new' }),
|
||||
JSON.stringify(body))
|
||||
check('POST 请求体:工作流、名称、时区、输入模板', body?.workflow_id === 'wf-weekly' && body?.name === '门店月报 · 新一期'
|
||||
&& body?.timezone === 'Asia/Shanghai' && JSON.stringify(body?.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
|
||||
check('创建后进详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new-event'))
|
||||
await page.locator('[data-trigger-mode-fixed="event"]').waitFor()
|
||||
check('详情:触发方式只读,写明建好之后不能改', await page.locator('[data-trigger-modes]').count() === 0
|
||||
&& (await text(page, '[data-trigger-mode-fixed]')).includes('建好之后不能改'))
|
||||
await shot(page, 'event-new-dark')
|
||||
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('事件触发:列表、详情与触发历史', async () => {
|
||||
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT), structuredClone(S_EVENT_OFF)], fires: eventFires() })
|
||||
const list = await open('/schedules', { state })
|
||||
await list.page.locator('[data-schedule-row="s-event"]').waitFor()
|
||||
const row = list.page.locator('[data-schedule-row="s-event"]')
|
||||
check('列表:事件触发的标「事件触发」,cron 的不标', await row.locator('[data-schedule-trigger-kind="event"]').count() === 1
|
||||
&& await list.page.locator('[data-schedule-row="s-weekly"] [data-schedule-trigger-kind="event"]').count() === 0)
|
||||
check('列表:频率那里写触发说明', (await row.locator('[data-schedule-desc]').innerText()).includes(SALES_TRIGGER))
|
||||
const wait = await row.locator('[data-schedule-event-wait="waiting"]').innerText()
|
||||
check('列表:去抖中写收到几次、什么时候合成一次触发', wait.includes('已收到 2 次事件') && wait.includes('合成一次触发'), wait)
|
||||
const off = await list.page.locator('[data-schedule-row="s-event-off"] [data-schedule-event-wait="pending"]').innerText()
|
||||
check('列表:调度没开时照实说事件在等、不会派发', off.includes('3 条事件') && off.includes('调度未开启'), off)
|
||||
check('列表:上次触发链到那次运行', await row.locator('[data-schedule-last] a[href="/runs/run-fe1"]').count() === 1)
|
||||
|
||||
const { page, sent, errors } = await open('/schedules/s-event', { state })
|
||||
await page.locator('[data-trigger-section]').waitFor()
|
||||
check('详情:页头副标题是触发说明', (await page.locator('[data-schedules-page] header').innerText()).includes(SALES_TRIGGER))
|
||||
check('详情:读出来的对象、期次、去抖', await page.locator('[data-trigger-subject="table.version"]').inputValue() === 'ds-sales'
|
||||
&& await page.locator('[data-trigger-period]').inputValue() === 'new' && await page.locator('[data-trigger-debounce]').inputValue() === '300')
|
||||
check('详情:没有频率、只读的触发方式', await page.locator('[data-freq-preset]').count() === 0
|
||||
&& await page.locator('[data-trigger-mode-fixed="event"]').count() === 1)
|
||||
await page.locator('[data-trigger-debounce]').selectOption('900')
|
||||
await page.locator('[data-schedule-save]').click()
|
||||
await until(() => writes(sent, /^PATCH \/schedules\/s-event$/).length === 1)
|
||||
const patch = writes(sent, /^PATCH \/schedules\/s-event$/)[0]?.body
|
||||
check('只改去抖:PATCH 只带触发条件和版本,不带 cron', JSON.stringify(patch) === JSON.stringify({
|
||||
trigger: { event: 'table.version', debounce_s: 900, source_id: 'ds-sales', period: 'new' }, version: 2 }), JSON.stringify(patch))
|
||||
await saveDone(page)
|
||||
|
||||
await page.locator('#schedule-tab-history').click()
|
||||
await page.locator('[data-fire-id="fe1"]').waitFor()
|
||||
const fe1 = await text(page, '[data-fire-id="fe1"] [data-fire-event]')
|
||||
check('触发历史:写「由事件触发:…」和合并了几次', fe1.includes(`由事件触发:${SALES_EVENT}`) && fe1.includes('合并了 2 次事件'), fe1)
|
||||
check('……类型标「事件」', (await text(page, '[data-fire-id="fe1"] [data-fire-kind-chip]')).includes('事件'))
|
||||
const fe2 = await text(page, '[data-fire-id="fe2"] [data-fire-event]')
|
||||
check('……立即运行按示例值、没发起的照实写:写「按事件」,不说成由事件触发', fe2.includes(`按事件:${SALES_EVENT}`)
|
||||
&& fe2.includes('立即运行,事件为示例值') && !fe2.includes('由事件触发')
|
||||
&& (await text(page, '[data-fire-id="fe2"]')).includes('还没有发生过这种事件'), fe2)
|
||||
check('……上一次没结束跳过的写第几层', (await text(page, '[data-fire-id="fe3"] [data-fire-event]')).includes('触发链第 2 层'))
|
||||
await shot(page, 'event-history-dark')
|
||||
// 390px 窄屏、浅色:事件那一节(三种事件的按钮、对象下拉里带原因的长名字)不撑出横向滚动
|
||||
const narrow = await open('/schedules/s-event', { state, width: 390, height: 844, theme: 'light' })
|
||||
await narrow.page.locator('[data-trigger-section]').waitFor()
|
||||
await narrow.page.locator('[data-trigger-event="workflow.finished"]').click()
|
||||
await narrow.page.locator('[data-trigger-app]').waitFor()
|
||||
await sleep(300)
|
||||
check('390px 浅色:事件那一节不横向溢出,对象、去抖、保存在屏幕里', await overflow(narrow.page) <= 1
|
||||
&& await inViewX(narrow.page, '[data-trigger-subject]') && await inViewX(narrow.page, '[data-trigger-debounce]')
|
||||
&& await inViewX(narrow.page, '[data-schedule-save]'), `${await overflow(narrow.page)}px`)
|
||||
await shot(narrow.page, 'event-390-light')
|
||||
const all = [...errors, ...list.errors, ...narrow.errors]
|
||||
check('没有页面报错', !all.length, all.join(' | '))
|
||||
})
|
||||
|
||||
await section('事件触发:运行详情页头写「由事件触发」', async () => {
|
||||
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT)], runs: [...RUNS, RUN_EVENT, RUN_SAMPLE] })
|
||||
const { page, errors } = await open('/runs/run-event-1', { state })
|
||||
await page.locator('[data-run-detail="run-event-1"]').waitFor({ timeout: 10000 })
|
||||
const link = page.locator('[data-run-detail] header [data-run-schedule-link]')
|
||||
const said = await until(async () => {
|
||||
const t = (await link.innerText().catch(() => '')).trim()
|
||||
return t.includes('门店月报 · 新一期') ? t : null
|
||||
})
|
||||
check('页头写「由事件触发:<事件>(定时任务「X」)」,是到那个定时任务的链接',
|
||||
said === `由事件触发:${SALES_EVENT}(定时任务「门店月报 · 新一期」)` && await link.getAttribute('href') === '/schedules/s-event'
|
||||
&& await link.getAttribute('data-run-schedule-event') === '', said ?? (await link.innerText().catch(() => '没有链接')))
|
||||
const manual = await open('/runs/run-event-2', { state })
|
||||
await manual.page.locator('[data-run-detail="run-event-2"]').waitFor({ timeout: 10000 })
|
||||
const mHead = manual.page.locator('[data-run-detail] header').first()
|
||||
await until(async () => (await mHead.locator('[data-run-schedule-link]').innerText().catch(() => '')).includes('门店月报 · 新一期'))
|
||||
const mText = oneLine([await mHead.innerText()])
|
||||
check('立即运行按最近一次事件发起的:署名照写,写「立即运行…按事件」,不说成由事件触发', mText.includes('检查员甲 发起')
|
||||
&& mText.includes(`立即运行定时任务「门店月报 · 新一期」,按事件:${SALES_EVENT}`) && !mText.includes('由事件触发'), mText.slice(0, 200))
|
||||
check('没有页面报错', ![...errors, ...manual.errors].length, [...errors, ...manual.errors].join(' | '))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 窄屏、浅色
|
||||
|
||||
await section('窄屏 390px 与浅色', async () => {
|
||||
|
||||
Reference in New Issue
Block a user