diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 7d018977..a78bdf49 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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)} diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 708aa247..9862394a 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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 "正式运行" diff --git a/backend/app/scheduler/__init__.py b/backend/app/scheduler/__init__.py index 57b7c9d7..d6a48183 100644 --- a/backend/app/scheduler/__init__.py +++ b/backend/app/scheduler/__init__.py @@ -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、验收副本)不管从哪条路径起服务, diff --git a/backend/app/scheduler/outbox.py b/backend/app/scheduler/outbox.py index 423716a7..c61b70b7 100644 --- a/backend/app/scheduler/outbox.py +++ b/backend/app/scheduler/outbox.py @@ -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 []) diff --git a/backend/tests/test_schedule_events_api.py b/backend/tests/test_schedule_events_api.py index 1dd064b9..fe86acb2 100644 --- a/backend/tests/test_schedule_events_api.py +++ b/backend/tests/test_schedule_events_api.py @@ -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]})") diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index e5e505cc..d92e3015 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -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('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }), /** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */ variables: (opts?: RequestOptions) => get('/schedules/variables', opts), + /** + * 事件触发任务的模板里能用的变量:schedule.*(上一次计划触发时间除外)加上这种事件的 event.*(docs/design/orchestration-round + * §4)。老服务端不认这两个参数,照旧给 cron 的那一份 + */ + eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) => + get(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts), + /** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */ + triggerOptions: (opts?: RequestOptions) => get('/schedules/trigger-options', opts), /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ run: (id: string) => post(`${at(id)}/run`), /** 触发历史,新的在前。before 是上一页给的 next_before */ diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8f01ebb6..70954597 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3985,11 +3985,13 @@ export const SCHEDULE_TEXT = { openRun: '查看运行', // ---- 触发 - fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record, + fireKind: { scheduled: '定时', manual: '手动', misfire: '补发', event: '事件' } as Record< + import('../types').ScheduleFireKind, string>, fireKindHint: { scheduled: '到点按计划触发', manual: '手动「立即运行」', misfire: '错过触发时间后,在补发宽限内恢复时补发的一次', + event: '由数据事件触发(去抖结束的时刻)', } as Record, /** * 错过、没有补发的(服务端记 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: { diff --git a/frontend/src/lib/termsTrigger.ts b/frontend/src/lib/termsTrigger.ts new file mode 100644 index 00000000..13f4cc1d --- /dev/null +++ b/frontend/src/lib/termsTrigger.ts @@ -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, + modeHint: { + cron: '按设定的频率到点发起运行', + event: '数据有变化时发起运行:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化', + } as Record, + /** 修改时:触发方式建好之后不能改 */ + modeFixed: (label: string) => `触发方式:${label}。建好之后不能改,要换请新建一个定时任务`, + + // ---- 事件那一节 + section: '触发事件', + eventLabel: '事件', + event: { + 'table.version': '上传表格出了新的一期', + 'workflow.finished': '上游工作流运行成功结束', + 'source.changed': '数据源有要处理的变化', + } as Record, + eventHint: { + 'table.version': '按期累积的上传表格导入了新的一期并启用时触发;每次上传替换整张表的表格没有这个事件', + 'workflow.finished': '选中的工作流有一次正式运行成功结束时触发;输入模板可以引用它的出口字段', + 'source.changed': '数据源的探查、剖析新记下「要处理」级的变化时触发;同一次探查或剖析产生的一批只触发一次', + } as Record, + subject: { 'table.version': '上传表格', 'workflow.finished': '上游工作流', 'source.changed': '数据源' } as Record< + ScheduleEventKind, string>, + subjectPlaceholder: (label: string) => `选择${label}`, + subjectEmpty: { + 'table.version': '还没有上传表格', + 'workflow.finished': '还没有工作流', + 'source.changed': '还没有数据源', + } as Record, + /** 不能选的对象:名字后面接服务端给的原因 */ + 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: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值', +} diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 1b363bd1..da178c84 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -595,7 +595,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { · {/* 定时任务发起的:写成到那个定时任务的链接(键盘可达;记录页的「定时」标记只能鼠标点) */} - {run.schedule_id ? : ( + {run.schedule_id ? : ( {run.started_by || '未署名'} 发起 diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx index d6f8a67e..5f0378a9 100644 --- a/frontend/src/pages/schedules/FireHistory.tsx +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -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) && ( )} + {f.event && } {reason && (
{T.reason}:{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 ( +
+ {event.sample ? TT.fireSample(event.text) : TT.fireEvent(event.text)} + {notes.length > 0 && ({notes.join(';')})} +
+ ) +} + /** * 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析 * 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果; diff --git a/frontend/src/pages/schedules/ScheduleEditor.tsx b/frontend/src/pages/schedules/ScheduleEditor.tsx index 26170977..60a61c7a 100644 --- a/frontend/src/pages/schedules/ScheduleEditor.tsx +++ b/frontend/src/pages/schedules/ScheduleEditor.tsx @@ -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 }: { touch('workflow')} /> + set({ triggerKind })} /> - set({ timezone })} - freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} - tzError={previewSays('timezone') ?? serverError(['timezone'])} /> + {event ? ( + 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'])} /> + ) : ( + set({ timezone })} + freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} + tzError={previewSays('timezone') ?? serverError(['timezone'])} /> + )}
@@ -196,13 +213,13 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: { error={serverError(['run_class'])} onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} /> set({ inputs })} />

{T.overlap}

{T.overlapText}

- + set({ subjectId: e.target.value })}> + + {value.subjectId && !listed && } + {objects.map((o) => ( + + ))} + + ) + } + + return ( +
+

{TT.section}

+
+
{TT.eventLabel}
+
+ {EVENT_KINDS.map((e: ScheduleEventKind) => ( + + ))} +
+

{TT.eventHint[kind]}

+
+ + + {picker} + + {error && ( + + )} + + {kind === 'table.version' && ( + + + + )} + {kind === 'workflow.finished' && options.status === 'ok' && ( +
+
+ + + + + + +
+

{TT.filterHint}

+

{TT.chainHint(options.data.max_depth)}

+
+ )} + + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts index 6d06a478..73acc350 100644 --- a/frontend/src/pages/schedules/model.ts +++ b/frontend/src/pages/schedules/model.ts @@ -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 = {} 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, 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> = {} 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 diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx index 7b6e48a4..d6c17ff3 100644 --- a/frontend/src/pages/schedules/parts.tsx +++ b/frontend/src/pages/schedules/parts.tsx @@ -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 {T.nextPaused} + if (schedule.trigger_kind === 'event') return const d = parseServerTime(schedule.next_run_at) if (!d) return {T.nextNone} return } +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 {TT.waiting(st.waiting, when)} + } + if (st && st.events > 0) { + return ( + + {st.scheduler_on ? TT.pending(st.events) : TT.pendingOff(st.events)} + + ) + } + return ( + + {TT.nextEvent} + + ) +} + +/** 列表、详情页头里「事件触发」的小标签:和运行类别的标签并排 */ +export function EventChip() { + return {TT.chip} +} + // ------------------------------------------------------------------------- // 触发 // ------------------------------------------------------------------------- @@ -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) => ( <> @@ -365,16 +407,17 @@ export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: strin const source = scheduleGone(scheduleId) ? ( - {body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))} + {body(eventText ? said(true) : auto ? T.runSourceGone(name) : T.runSourceManualGone(name))} ) : ( - {body(auto ? T.runSource(name) : T.runSourceManual(name))} + {body(eventText ? said(false) : auto ? T.runSource(name) : T.runSourceManual(name))} ) if (auto || !startedBy) return source diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cce85f52..5fe469f3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 & { debounce_s?: number } +} + +export interface SchedulePreviewRequest { + trigger_kind?: ScheduleTriggerKind + trigger?: Omit & { 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[] diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 8af36ea7..b1e08b8e 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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 () => {