diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index cb733715..d6af2afd 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -1,7 +1,40 @@ /** - * 定时任务的接口(/api/schedules)。现在是空的,定时任务的工作包往里加,挂在 api.schedules 上。 + * 定时任务的接口(/api/schedules),挂在 api.schedules 上。字段名和形状照前后端共用的接口约定(types.ts 末尾那一段)。 * * 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入 * 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。 + * + * 错误都是 ApiError,message 是服务端给的中文 detail:修改时版本不符回 409,预览非法回 422 且 field 指明是 + * cron / timezone / input_template 哪一项(ApiError.field)。老服务端没有这些接口,回 404 且没有 code */ -export const schedules = {} +import type { + Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult, + ScheduleVariable, ScheduleWebhookTest, +} from '../types' +import { del, get, patch, post, qs, request, type RequestOptions } from './http' + +const at = (id: string) => `/schedules/${encodeURIComponent(id)}` + +export const schedules = { + list: (opts?: RequestOptions) => get<{ items: Schedule[] }>('/schedules', opts), + get: (id: string, opts?: RequestOptions) => get(at(id), opts), + create: (body: ScheduleCreate) => post('/schedules', body), + /** 只带改了的字段和读到的版本;Webhook 的地址省略表示不改、空串表示删除 */ + update: (id: string, body: SchedulePatch) => patch(at(id), body), + remove: (id: string) => del(at(id)), + /** + * 接下来几次触发时间和渲染出的输入,只算不存。signal 用来丢掉过时的请求:频率还在改、又发了新的一次时, + * 上一次的答复不再要(取消照旧抛 AbortError) + */ + preview: (body: SchedulePreviewRequest, opts?: { signal?: AbortSignal }) => + request('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }), + /** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */ + variables: (opts?: RequestOptions) => get('/schedules/variables', opts), + /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ + run: (id: string) => post(`${at(id)}/run`), + /** 触发历史,新的在前。before 是上一页给的 next_before */ + fires: (id: string, page?: { limit?: number; before?: string | null }, opts?: RequestOptions) => + get(`${at(id)}/fires${qs(page)}`, opts), + /** 用已保存的 Webhook 设置发一条测试消息(不用页面上还没保存的) */ + testWebhook: (id: string) => post(`${at(id)}/webhook/test`), +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index da35a65f..3db8cd27 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2732,12 +2732,329 @@ export const COPILOT_CONTEXT_TEXT = { /** 尚未完成的功能在界面上的统一说法 */ const PENDING_TEXT = '功能开发中' -/** 定时任务:导航、命令面板和定时任务页 */ +/** + * 定时任务:导航、命令面板、定时任务页(pages/schedules)和记录页里的「定时」标记。 + * + * 触发、跳过、补发各有固定说法:「触发」是到点(或手动)的那一下,「发起」是由它开始一次运行;跳过分「上次未结束」 + * 和「错过时间」两种,不笼统写「失败」。时间都按定时任务自己的时区写,悬停另给本机时间 + */ export const SCHEDULE_TEXT = { title: '定时任务', hint: '按设定的周期自动发起工作流运行', - pending: PENDING_TEXT, - pendingHint: '定时任务尚未开放,完成后可在此处管理', + + // ---- 列表 + create: '新建定时任务', + emptyTitle: '还没有定时任务', + emptyBody: '定时任务按设定的周期自动发起工作流运行,适合日报、周报这类定期产出的结果。选择工作流、设定频率和输入即可创建,每次触发都记入触发历史。', + unsupported: '服务端暂不支持定时任务', + unsupportedHint: '当前服务端没有定时任务接口,请升级服务端后再使用', + listLabel: '定时任务列表', + formalChip: '正式运行', + pausedChip: '已暂停', + nextRun: '下次运行', + lastFire: '上次触发', + neverFired: '尚未触发', + /** 暂停中的没有下一次 */ + nextPaused: '暂停中,不会触发', + /** 启用着却算不出下一次(如 2 月 30 日) */ + nextNone: '往后不会再触发', + /** 计划时间已过、还没有这一次的触发记录:调度可能没开,或者正在发起 */ + nextOverdue: '已到触发时间', + /** 悬停提示:按哪个时区、本机是几点 */ + zoneTitle: (when: string, tz: string) => `${when}(${tz})`, + localTitle: (when: string) => `本机时间 ${when}`, + enable: '启用', + enableLabel: (name: string) => `启用定时任务「${name}」`, + enabledOk: (name: string) => `已启用「${name}」`, + pausedOk: (name: string) => `已暂停「${name}」,到点不再发起运行`, + runNow: '立即运行', + runNowLabel: (name: string) => `立即运行定时任务「${name}」`, + edit: '编辑', + editLabel: (name: string) => `编辑定时任务「${name}」`, + deleteLabel: (name: string) => `删除定时任务「${name}」`, + deleteTitle: (name: string) => `删除定时任务「${name}」?`, + deleteConsequences: ['不再按计划发起运行,删除后不可恢复', '已发起的运行及其记录不受影响'], + deleteConfirm: '删除定时任务', + deleted: (name: string) => `已删除定时任务「${name}」`, + /** 启用开关遇到版本不符(409):服务端那句是写给表单保存的,开关用这句,原话放进详情 */ + toggleConflict: (name: string) => `「${name}」已在别处被修改,已重新载入最新状态,请确认后再切换`, + + // ---- 立即运行的结果 + runStarted: '已发起运行', + runSkipped: '上一次运行尚未结束,本次已按重叠策略跳过', + runFailed: (reason: string | null) => (reason ? `未能发起运行:${reason}` : '未能发起运行'), + openRun: '查看运行', + + // ---- 触发 + fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record, + fireKindHint: { + scheduled: '到点按计划触发', + manual: '手动「立即运行」', + misfire: '错过触发时间后,在补发宽限内恢复时补发的一次', + } as Record, + /** + * 错过、没有补发的(服务端记 kind=misfire、status=skipped_misfire):类型按「定时」写。没有补发的原因有两种(超出 + * 补发宽限;宽限内恢复时积压的触发不补,只发起最近一次),这里不替服务端认定是哪一种,本条记了原因时指向它 + */ + fireKindMissedHint: (hasReason: boolean) => `按计划应在此时触发,错过后没有补发${hasReason ? ',原因见本条记录' : ''}`, + fireStatus: { + started: '已发起', + skipped_overlap: '上次未结束已跳过', + skipped_misfire: '错过已跳过', + failed_to_start: '发起失败', + } as Record, + /** + * 结局的一般说明,本条没有记原因时悬停用:只说这种结局是什么,不指向不存在的原因。错过已跳过的两种原因都适用, + * 发起失败也可能是手动立即运行,不写「到点」 + */ + fireStatusHint: { + started: '已发起运行', + skipped_overlap: '上一次运行还没有结束,按重叠策略跳过了这一次', + skipped_misfire: '服务停机等原因错过了触发时间,这一次没有发起', + failed_to_start: '未能发起运行,服务端未提供原因', + } as Record, + event: { finished: '运行结束', failed: '运行失败', awaiting_approval: '等待审批' } as Record, + noticeStatus: { ok: '已发送', failed: '发送失败' } as Record<'ok' | 'failed', string>, + + // ---- 详情 + tabs: { settings: '设置', history: '触发历史' } as Record<'settings' | 'history', string>, + back: '返回列表', + notFound: '定时任务不存在或已被删除', + notFoundHint: '请返回列表查看现有的定时任务', + + // ---- 表单:基本 + newTitle: '新建定时任务', + name: '名称', + namePlaceholder: '例如:门店周报', + workflow: '工作流', + workflowPlaceholder: '搜索并选择工作流', + workflowNone: '没有匹配的工作流', + workflowEmpty: '还没有工作流,请先在编排页创建并保存。', + toStudio: '去编排', + /** 定时任务指向的工作流已经不在列表里 */ + workflowGone: (name: string) => `工作流「${name}」已不存在,请重新选择`, + workflowDraft: '草稿', + workflowVersion: (status: string, v: number) => `${status} v${v}`, + + // ---- 表单:频率 + freq: '频率', + preset: { daily: '每天', weekly: '每周', monthly: '每月', hourly: '每隔几小时', custom: '自定义 cron' } as Record< + 'daily' | 'weekly' | 'monthly' | 'hourly' | 'custom', string>, + time: '时间', + weekdays: '星期', + weekdayShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] as readonly string[], + monthday: '日期', + monthdayOption: (d: number) => `${d} 日`, + monthdayHint: '没有这一天的月份不触发', + everyHours: '间隔', + everyHoursOption: (n: number) => `每 ${n} 小时`, + minute: '第几分', + hourlyHint: (n: number, m: number) => `从 0 点起每 ${n} 小时一次,每次在第 ${m} 分`, + cron: 'cron 表达式', + cronHint: '五段,依次为分、时、日、月、星期,例如 0 9 * * 1-5 表示工作日 09:00', + timezone: '时区', + timezoneHint: '触发时间按这个时区计算', + timezoneLocal: '本机', + timezoneLabel: { + 'Asia/Shanghai': '北京', 'Asia/Hong_Kong': '香港', 'Asia/Taipei': '台北', 'Asia/Singapore': '新加坡', + 'Asia/Tokyo': '东京', 'Asia/Seoul': '首尔', 'Asia/Kolkata': '印度', 'Asia/Dubai': '迪拜', 'Europe/London': '伦敦', + 'Europe/Paris': '巴黎', 'Europe/Berlin': '柏林', 'America/New_York': '纽约', 'America/Chicago': '芝加哥', + 'America/Los_Angeles': '洛杉矶', UTC: '协调世界时', + } as Record, + + // ---- 表单:预览 + preview: '接下来 5 次触发', + previewLoading: '正在计算…', + previewIdle: '填好频率后在此预览触发时间', + previewNone: '按这个频率往后不会触发', + previewFailed: '无法预览触发时间', + previewZone: (tz: string) => `按 ${tz} 计算`, + + // ---- 表单:运行类别 + runClass: '运行类别', + formal: '正式运行', + formalHint: '从已发布版本发起,结果出具、可审计。工作流须已发布', + formalVersion: (v: number) => `每次按当时的发布版本发起,当前为 v${v}`, + exploratory: '探索运行', + exploratoryHint: '使用工作流当前保存的内容,结果不出具', + /** 已保存的定时任务、这次没动工作流和运行类别,工作流后来撤了发布:保存照常,到点会发起失败 */ + notPublished: '这个工作流目前没有发布版本:到点时无法发起正式运行,会记为发起失败。请先发布,或改用探索运行', + + // ---- 表单:输入 + inputs: '输入', + inputsHint: '可填固定值或模板,模板中的变量按触发时间计算;留空的字段按工作流的默认值', + inputsPick: '选择工作流后在此填写输入', + inputsNone: '这个工作流没有输入字段', + inputsLoading: '正在读取输入字段…', + inputsFailed: '无法读取输入字段', + inputsFromVersion: (v: number) => `字段取自已发布的 v${v}`, + inputsFromDraft: '字段取自工作流当前保存的内容', + required: '必填', + /** 已保存的定时任务、这次没动输入:保存照常,留空到点会发起失败 */ + requiredNoDefault: '必填且没有默认值:留空时到点会发起失败', + /** 新建、或者这次改了工作流、运行类别、输入:服务端保存时就会拒 */ + requiredFill: '必填且没有默认值,请填写后再保存', + defaultValue: (v: string) => `默认:${v}`, + variables: '插入变量', + variablesClose: '收起变量', + variablesLabel: (field: string) => `插入到「${field}」的变量`, + variablesEmpty: '服务端没有提供可用的变量', + variableTitle: (name: string, example: string) => (example ? `${name},例如 ${example}` : name), + rendered: (v: string) => `下次触发时为:${v}`, + renderedEmpty: '下次触发时为空', + staleField: '工作流中已没有这个字段', + /** 画布上有、正式运行用的已发布版本里没有 */ + staleNotInVersion: (v: number) => `已发布的 v${v} 中没有这个字段`, + /** 接在上面两句后面:交上去会被拒 */ + staleRemove: '保存前请移除', + removeField: '移除', + + // ---- 表单:重叠与补发 + overlap: '重叠与补发', + overlapText: '上一次运行还没有结束时,跳过这一次,记入触发历史', + misfire: '错过触发时间', + misfireHint: '服务停机等原因错过触发时间时,在宽限内恢复则补发一次,超过则跳过', + misfireOption: (span: string) => `${span}内补发`, + span: { day: (n: number) => `${n} 天`, hour: (n: number) => `${n} 小时`, minute: (n: number) => `${n} 分钟`, second: (n: number) => `${n} 秒` }, + enabledOnCreate: '创建后立即启用', + + // ---- 表单:Webhook + webhook: 'Webhook 通知', + webhookOn: '运行有结果时发送 Webhook 通知', + webhookFormat: '格式', + format: { generic: '通用', feishu: '飞书', wecom: '企业微信', dingtalk: '钉钉' } as Record< + import('../types').ScheduleWebhookFormat, string>, + formatHint: { + generic: '以 JSON 发送到指定地址', + feishu: '飞书群机器人的 Webhook 地址', + wecom: '企业微信群机器人的 Webhook 地址', + dingtalk: '钉钉群机器人的 Webhook 地址', + } as Record, + urlPlaceholder: { + generic: 'https://example.com/hooks/agentlab', + feishu: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', + wecom: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', + dingtalk: 'https://oapi.dingtalk.com/robot/send?access_token=…', + } as Record, + url: '地址', + urlSaved: '已保存的地址', + urlSavedHint: '完整地址已加密保存,只显示主机和末 4 位', + urlReplace: '替换地址', + urlKeep: '保留原地址', + webhookRemove: '删除 Webhook', + webhookRemoving: '保存后将删除这个 Webhook', + webhookUndoRemove: '撤销删除', + secret: '签名密钥', + secretHint: { + generic: '可选。填写后每条消息带签名,接收端可据此校验来源', + feishu: '可选。机器人开启了签名校验时填写', + wecom: '企业微信群机器人不使用签名,留空即可', + dingtalk: '可选。机器人开启了加签时填写', + } as Record, + secretSet: '已设置', + secretReplace: '替换', + secretClear: '清除', + secretClearing: '保存后将清除签名密钥', + secretUndo: '撤销', + secretNew: '新的签名密钥', + events: '通知哪些事件', + test: '发送测试消息', + testing: '正在发送…', + testNeedSave: '保存后才能发送测试消息', + testDirty: '测试使用已保存的设置,请先保存修改', + testOk: '测试消息已送达', + testFailed: '测试消息发送失败', + /** 结局后面接状态码和说明(两样都可能没有) */ + testResult: (head: string, status: number | null, detail: string) => + `${head}${status != null ? `(HTTP ${status})` : ''}${detail ? `:${detail}` : ''}`, + + // ---- 通知里的运行链接(设置 notify 组,所有定时任务共用) + linkBase: '通知中的运行链接', + linkBaseValue: (url: string) => `按 ${url} 拼接`, + linkBaseUnset: '未设置服务访问地址,通知中不附带运行链接', + linkBaseEdit: '修改', + linkBaseSet: '设置', + linkBaseLabel: '服务访问地址', + linkBaseHint: '收件人打开运行链接用的地址,例如 https://agentlab.example.com。所有定时任务共用', + linkBasePlaceholder: 'https://agentlab.example.com', + linkBaseInvalid: '请输入以 http:// 或 https:// 开头的地址', + linkBaseSaved: '已保存服务访问地址', + linkBaseCleared: '已清除服务访问地址', + linkBaseFailed: '无法读取服务访问地址', + + // ---- 表单:保存 + save: '保存', + createSubmit: '创建', + discard: '撤销修改', + retry: '重试', + saving: '正在保存…', + cancel: '取消', + saved: '已保存', + created: (name: string) => `已创建定时任务「${name}」`, + missing: (items: string) => `还需处理:${items}`, + previewBlocks: '请先修正频率、时区或输入中标出的问题', + saveFailed: '保存失败', + conflict: '保存失败:这个定时任务已在别处被修改,请重新载入后再修改', + reload: '重新载入', + reloadTitle: '重新载入最新设置?', + reloadConsequences: ['页面上未保存的修改将丢失'], + reloadConfirm: '放弃修改并重新载入', + leaveTitle: '定时任务有未保存的修改', + leaveConsequences: ['离开后,这些修改将丢失'], + leaveConfirm: '放弃修改', + + // ---- 表单的问题(写在对应字段下) + problem: { + name: '请填写名称', + workflow: '请选择工作流', + timezone: '请选择时区', + cronEmpty: '请填写 cron 表达式', + cronFields: 'cron 表达式应为五段,以空格分隔', + minute: '请填写 0–59 之间的分钟', + time: '请填写时间', + weekdays: '至少选择一天', + url: '请填写 Webhook 地址', + urlInvalid: '请输入以 http:// 或 https:// 开头的地址', + secret: '请填写新的签名密钥', + events: '至少选择一个事件', + notPublished: '这个工作流还没有发布版本,正式运行需要已发布的版本。请先发布,或改用探索运行', + inputsMissing: (names: string) => `必填输入「${names}」还没有填写`, + inputsUnknown: (names: string) => `输入「${names}」对不上工作流的输入字段,请移除`, + }, + /** 「还需处理」里各项的叫法 */ + problemField: { + name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', runClass: '运行类别', inputs: '输入', url: 'Webhook 地址', + secret: '签名密钥', events: '通知事件', + } as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events', string>, + + // ---- 相对时间(将来) + until: { + soon: '不到 1 分钟后', + minutes: (n: number) => `${n} 分钟后`, + hours: (n: number) => `${n} 小时后`, + days: (n: number) => `${n} 天后`, + }, + + // ---- 触发历史 + history: '触发历史', + historyHint: '每次触发都记在这里,包括跳过和发起失败的', + historyEmpty: '还没有触发记录', + historyEmptyHint: '到点后的每次触发都会记在这里,包括跳过和发起失败的', + historyRefresh: '刷新', + fireAt: '计划时间', + fireAtManual: '触发时间', + reason: '原因', + noRun: '未发起运行', + notified: '通知', + loadMore: '加载更多', + allLoaded: '已全部加载', + shown: (n: number) => `已显示 ${n} 条`, + + // ---- 记录页的定时标记 + runTag: '定时', + runTagTitle: (name?: string | null) => (name ? `由定时任务「${name}」发起,点击查看` : '由定时任务发起,点击查看'), + /** 读屏念的来源(整行是打开运行的按钮,标记本身点不到,至少在名字里听到) */ + runTagSr: (name?: string | null) => (name ? `由定时任务「${name}」发起` : '由定时任务发起'), } /** 运行对比:记录页里两次运行的对照。配方前后对照另有 COMPARE_TEXT,别混用 */ diff --git a/frontend/src/pages/runs/RunList.tsx b/frontend/src/pages/runs/RunList.tsx index 70ad18a0..cf2ed8ff 100644 --- a/frontend/src/pages/runs/RunList.tsx +++ b/frontend/src/pages/runs/RunList.tsx @@ -16,6 +16,7 @@ import { headlineMs, idTail, isLiveRun, runShape, runTiming, timingTitle, type ParsedQuery, } from './model' import { ClassChip, LiveElapsed, TierChip } from './parts' +import { ScheduleTag, useScheduleNames } from '../schedules/parts' import type { ApprovalQueue, RunList, StatusCounts } from './useRunsData' // ------------------------------------------------------------------------- @@ -191,6 +192,8 @@ export function RunRows({ }) { const changed = useChangedRows(list.rows) const now = useTicker(60_000) + // 定时任务发起的运行在名字旁标「定时」,悬停写是哪个定时任务:名字按需取一次 + const scheduleNames = useScheduleNames(list.rows.map((r) => r.schedule_id)) if (list.state === 'loading') { return
@@ -241,6 +244,7 @@ export function RunRows({ flash={changed.get(run.id)} now={now} onOpen={onOpen} + scheduleName={run.schedule_id ? scheduleNames.get(run.schedule_id) : undefined} /> ))} @@ -295,10 +299,12 @@ function useChangedRows(rows: Run[]): Map { return changed } -function RunRow({ run, code, approval, selected, dup, flash, now, onOpen }: { +function RunRow({ run, code, approval, selected, dup, flash, now, onOpen, scheduleName }: { run: Run; code: StatusCode; approval?: Approval; selected: boolean; dup: boolean /** 状态刚变过:第几次变化(换 key 重播闪光) */ flash?: number; now: number; onOpen: (id: string) => void + /** 发起它的定时任务的名字(不是定时任务发起的、或名字还没取到时没有) */ + scheduleName?: string }) { const meta = statusMeta(code) const timing = runTiming(run) @@ -341,6 +347,7 @@ function RunRow({ run, code, approval, selected, dup, flash, now, onOpen }: { {idTail(run.workflow_id)} )} + {run.schedule_id && } diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx new file mode 100644 index 00000000..3e008910 --- /dev/null +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { ChevronRight, History, RotateCw } from 'lucide-react' +import { api } from '../../api/client' +import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui' +import { formatDateTime, parseServerTime } from '../../lib/format' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { Schedule, ScheduleFire } from '../../types' +import { FireKindChip, FireStatusChip, ZonedTime } from './parts' + +const PAGE = 20 + +/** + * 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败 + * 写原因;发起了的链到那次运行、写运行现在的状态;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取 + */ +export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) { + const [items, setItems] = useState(null) + const [next, setNext] = useState(null) + const [error, setError] = useState(null) + const [loadingMore, setLoadingMore] = useState(false) + const now = useTicker(60_000) + // 从头重取时作废还在路上的「加载更多」:两份答复交错接上会重复、会漏 + const seq = useRef(0) + const id = schedule.id + + const load = useCallback(async () => { + const n = ++seq.current + try { + const page = await api.schedules.fires(id, { limit: PAGE }) + if (n !== seq.current) return + setItems(page.items ?? []) + setNext(page.next_before ?? null) + setError(null) + } catch (e) { + if (n === seq.current) setError(e) + } + }, [id]) + useEffect(() => { void load() }, [load, refreshKey]) + + const more = async () => { + if (!next || loadingMore) return + const n = seq.current + setLoadingMore(true) + try { + const page = await api.schedules.fires(id, { limit: PAGE, before: next }) + if (n !== seq.current) return + setItems((cur) => { + const seen = new Set((cur ?? []).map((f) => f.id)) + return [...(cur ?? []), ...(page.items ?? []).filter((f) => !seen.has(f.id))] + }) + setNext(page.next_before ?? null) + } catch (e) { + toast.error(e) + } finally { + setLoadingMore(false) + } + } + + let body + if (items === null) { + body = error ? void load()} compact /> : + } else if (!items.length) { + body = ( +
+ } title={T.historyEmpty} body={T.historyEmptyHint} offline={false} /> +
+ ) + } else { + body = ( +
+
    + {items.map((f) => )} +
+
+ {T.shown(items.length)} + {next ? ( + + ) : · {T.allLoaded}} +
+
+ ) + } + + return ( +
+
+
+

{T.history}

+

{T.historyHint}

+
+ +
+ {body} +
+ ) +} + +function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) { + const at = parseServerTime(f.fire_at) + return ( +
  • +
    + {f.kind === 'manual' ? T.fireAtManual : T.fireAt} + {at ? : —} + + + + {f.run_id ? ( + + {f.run_status && } + {T.openRun} + + + ) : {T.noRun}} +
    + {f.reason && ( +
    + {T.reason}:{f.reason} +
    + )} + {f.notified?.length > 0 && ( +
    + {T.notified} + {f.notified.map((n, i) => ( + + {T.event[n.event] ?? n.event} {T.noticeStatus[n.status] ?? n.status} + + ))} +
    + )} +
  • + ) +} diff --git a/frontend/src/pages/schedules/ScheduleDetail.tsx b/frontend/src/pages/schedules/ScheduleDetail.tsx new file mode 100644 index 00000000..bb5f3a2b --- /dev/null +++ b/frontend/src/pages/schedules/ScheduleDetail.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' +import { ArrowLeft, CalendarClock, Play, Trash2 } from 'lucide-react' +import { ApiError, api } from '../../api/client' +import { EmptyState, ErrorState, IconButton, PageHeader, Skeleton, Spinner, Tabs, toast, useTicker } from '../../components/ui' +import { leavePass } from '../../lib/leave' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import { useOnReconnect } from '../../store/catalog' +import type { Schedule } from '../../types' +import { FireHistory } from './FireHistory' +import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts' +import { ScheduleEditor } from './ScheduleEditor' + +const BACK = ( + + {T.back} + +) + +/** 新建定时任务(/schedules/new)。创建成功进它的详情页,替换掉这一条历史:后退不回到一张空表单 */ +export function ScheduleCreatePage() { + const navigate = useNavigate() + return ( +
    + } title={T.newTitle} subtitle={T.hint} actions={BACK} /> +
    + { + rememberScheduleNames([s]) + toast.ok(T.created(s.name)) + // 表单刚保存完、离开守卫还没来得及撤:这一跳带通行证 + navigate(`/schedules/${encodeURIComponent(s.id)}`, leavePass({ replace: true })) + }} + /> +
    +
    + ) +} + +type TabKey = 'settings' | 'history' + +/** + * 一个定时任务的详情(/schedules/:id):页头是名字、频率和启用开关、立即运行、删除;下面两个标签,设置(表单) + * 和触发历史(?tab=history)。切到触发历史时表单不卸载,改到一半的内容还在。 + * + * 两份状态分开记:schedule 是页头和摘要显示的、随时可以刷新的最新值;version 是表单保存时带的版本,只跟着自己的 + * 写操作(开关、保存)和明确的「重新载入」走。别人在这期间改过时表单保存就该 409,不能因为页头顺手刷新过就悄悄覆盖。 + * 唯一的例外是表单第一次挂上的那一刻:表单基于的就是那时的 schedule,版本对齐到它 + */ +export function ScheduleDetailPage({ id }: { id: string }) { + const navigate = useNavigate() + const [params, setParams] = useSearchParams() + const tab: TabKey = params.get('tab') === 'history' ? 'history' : 'settings' + const [schedule, setSchedule] = useState(null) + const [version, setVersion] = useState(undefined) + const [error, setError] = useState(null) + const [resetKey, setResetKey] = useState(0) + const [historyKey, setHistoryKey] = useState(0) + // 两个标签都是第一次打开时才挂上,之后切走也不卸载:直接打开触发历史时不必先把表单和预览请求一遍 + const [historySeen, setHistorySeen] = useState(tab === 'history') + const [settingsSeen, setSettingsSeen] = useState(tab === 'settings') + const [busy, setBusy] = useState(null) + const now = useTicker(30_000) + const loaded = useRef(false) + /** + * 读的序号:后发的读胜过先发的读;开关、保存成功时也加一,作废还在路上的读。那次读可能在写之前读到旧值、 + * 在写的答复之后才回来,落地会把页头的开关、名字盖回旧的(详情页没有定时刷新,错的会一直挂着) + */ + const seq = useRef(0) + const wrote = () => { seq.current++ } + + /** 读最新的定时任务,读到了返回 true。adopt:表单保存时也改用它的版本(首次打开、重新载入) */ + const load = useCallback(async (adopt: boolean): Promise => { + const n = ++seq.current + try { + const s = await api.schedules.get(id) + if (n !== seq.current) return false + rememberScheduleNames([s]) + setSchedule(s) + setError(null) + if (adopt) setVersion(s.version) + loaded.current = true + return true + } catch (e) { + if (n !== seq.current) return false + // 读到过之后刷新失败:页头照旧显示手上的,只在首次打开时整页报错 + if (!loaded.current || (e instanceof ApiError && e.status === 404)) setError(e) + else if (adopt) toast.error(e) + return false + } + }, [id]) + useEffect(() => { void load(true) }, [load]) + /** 版本不符之后的「重新载入」:表单换成服务端现在的设置(换 key 重新挂载) */ + const reload = async () => { + if (await load(true)) setResetKey((k) => k + 1) + } + useOnReconnect(() => { void load(false) }) + useEffect(() => { + if (tab === 'history') setHistorySeen(true) + else setSettingsSeen(true) + }, [tab]) + // 表单第一次挂上时版本对齐到此刻的 schedule(渲染中改状态,React 当场重渲染,表单挂上时两者已经一致)。 + // 直接打开触发历史时表单要等切过来才挂,这期间页头可能已经刷新过(立即运行之后、重连、开关冲突之后), + // 表单显示的是刷新后的内容,还拿首次打开时的版本保存就会撞上并不存在的 409 + const [formBased, setFormBased] = useState(false) + if (settingsSeen && schedule && !formBased) { + setFormBased(true) + if (version !== schedule.version) setVersion(schedule.version) + } + + const pickTab = (k: string) => setParams((p) => { + const next = new URLSearchParams(p) + if (k === 'history') next.set('tab', 'history') + else next.delete('tab') + return next + }, { replace: true }) + + const act = async (what: 'toggle' | 'run' | 'delete', fn: (s: Schedule) => Promise) => { + if (!schedule || busy) return + setBusy(what) + try { + await fn(schedule) + } finally { + setBusy(null) + } + } + const toggle = (enabled: boolean) => act('toggle', async (s) => { + const out = await setEnabled(s, enabled) + if (out === 'conflict') await load(false) + else if (out) { + wrote() + setSchedule(out) + // 表单的版本只在开关前后没有别人插进来时跟着走:页头是在别人改过之后刷新出来的,表单还是旧的那份, + // 照旧带旧版本,保存时由服务端说清 + setVersion((v) => (v === s.version ? out.version : v)) + } + }) + const run = () => act('run', async (s) => { + const out = await runNow(s, navigate) + if (!out) return + setSchedule((cur) => (cur ? { ...cur, last_fire: out.fire } : cur)) + setHistoryKey((k) => k + 1) + void load(false) + }) + const remove = () => act('delete', async (s) => { + if (await removeSchedule(s)) navigate('/schedules', leavePass({ replace: true })) + }) + + const missing = error instanceof ApiError && error.status === 404 && !isUnsupported(error) + let content + if (missing) { + content = ( +
    + } title={T.notFound} body={T.notFoundHint} offline={false} + action={{T.back}} /> +
    + ) + } else if (isUnsupported(error)) { + content = } title={T.unsupported} body={T.unsupportedHint} offline={false} /> + } else if (!schedule) { + content = error + ? void load(true)} /> + :
    + } + + const actions = ( +
    + {schedule && ( + <> + void toggle(v)} /> + + void remove()} disabled={!!busy} data-schedule-delete="" + className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={} /> + + )} + {BACK} +
    + ) + + return ( +
    + } title={schedule?.name ?? T.title} subtitle={schedule?.description_text} + actions={actions} /> + {content ?? (schedule && ( + <> +
    +
    + {T.nextRun} + +
    +
    + {T.lastFire} + +
    +
    + +
    + {settingsSeen && ( + + )} + {historySeen && ( + + )} +
    + + ))} +
    + ) +} diff --git a/frontend/src/pages/schedules/ScheduleEditor.tsx b/frontend/src/pages/schedules/ScheduleEditor.tsx new file mode 100644 index 00000000..6250fd75 --- /dev/null +++ b/frontend/src/pages/schedules/ScheduleEditor.tsx @@ -0,0 +1,742 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { Check } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api } from '../../api/client' +import { Field, Notice, Skeleton, Spinner, confirmDialog, toast, useRadioGroup } from '../../components/ui' +import { errorMessage } from '../../lib/errors' +import { parseServerTime } from '../../lib/format' +import { useLeaveGuard } from '../../lib/leave' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import { useCatalog, useCatalogListState } from '../../store/catalog' +import type { Schedule, SchedulePreview, ScheduleRunClass, ScheduleVariable, Workflow } from '../../types' +import { + FREQ_PRESETS, GRACE_OPTIONS, HOUR_STEPS, WEEKDAY_ORDER, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem, + graceLabel, graphFields, patchBody, rebaseForm, serverFieldValue, templateOf, timezoneOptions, type Freq, type FormField, + type FreqPreset, type InputField, type ScheduleForm, +} from './model' +import { isUnsupported } from './parts' +import { WebhookFields } from './WebhookFields' +import { WorkflowPicker } from './WorkflowPicker' + +/** + * 新建、修改定时任务的表单。宽屏左边是表单、右边是接下来 5 次触发的预览(跟着滚动停在顶上);窄屏预览排在 + * 频率下面,改了频率当场看得到。 + * + * schedule 为 null 是新建。修改时只交改了的字段,带上 version:详情页页头的开关改过之后,version 是开关交回的 + * 新版本,表单照常保存;别人在这期间改过就 409,表单不动,给「重新载入」。 + */ +export function ScheduleEditor({ schedule, version, onSaved, onReload }: { + schedule: Schedule | null + /** 保存时带的版本;不给用 schedule 自己的 */ + version?: number + onSaved: (saved: Schedule, created: boolean) => void + /** 版本不符时重新读服务端的设置(详情页重置表单) */ + onReload?: () => void +}) { + const workflows = useCatalog((s) => s.workflows) + const workflowsState = useCatalogListState('workflows') + const [base, setBase] = useState(schedule) + const [initial, setInitial] = useState(() => formOf(schedule)) + const [form, setForm] = useState(initial) + const [touched, setTouched] = useState>>({}) + /** 新建时没动过运行类别:选工作流时按它发没发布替人选上 */ + const runClassTouched = useRef(!!schedule) + /** 名称是选工作流时替人填的哪个名字:人没改过它时,换工作流名称跟着换;人改过就不再动 */ + const autoName = useRef(null) + const [saving, setSaving] = useState(false) + /** + * 上一次保存的失败。field 是服务端 422 指的那一项,at 是交上去时那一项的值(serverFieldValue): + * 人改了那一项,说明就不再摆着,免得看上去新值也不行 + */ + const [saveError, setSaveError] = useState<{ + message: string; detail?: string; conflict?: boolean; field?: string; at?: string | null + } | null>(null) + + const workflow = workflows.find((w) => w.id === form.workflowId) + const cron = cronOf(form.freq) + const template = useMemo(() => templateOf(form.inputs), [form.inputs]) + const preview = usePreview(cron, form.timezone, template, form.workflowId) + const fieldsOf = useInputFields(workflow, form.runClass) + // 修改时工作流列表还没取回来:输入字段是「正在读取」,不是「请先选择工作流」 + const fields: FieldsState = !workflow && form.workflowId && workflowsState !== 'ok' ? { status: 'loading' } : fieldsOf + const variables = useVariables() + + const changes = base ? patchBody(form, initial, base) : null + // 服务端保存时只在新建、或者改了工作流、运行类别、输入时才核对这三样:照着拦,不多拦也不少拦 + const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes)) + const problems = formProblems(form, base, { + published: workflow ? workflow.published_version ?? null : undefined, + fields: fields.status === 'ok' ? fields.fields : undefined, + recheck, + }) + const dirty = base ? changes != null : JSON.stringify(form) !== JSON.stringify(initial) + const previewField = preview.status === 'invalid' ? preview.field : undefined + /** 预览判定非法、并指明是这一项时,服务端的说明 */ + const previewSays = (field: string) => (preview.status === 'invalid' && preview.field === field ? preview.message : undefined) + const previewBlocks = previewField === 'cron' || previewField === 'timezone' || previewField === 'input_template' + const missing = (Object.keys(problems) as FormField[]).map((k) => T.problemField[k]) + const hint = missing.length ? T.missing(missing.join('、')) : previewBlocks ? T.previewBlocks : '' + const canSave = !saving && !missing.length && !previewBlocks && dirty + + useLeaveGuard(dirty && !saving, () => confirmDialog({ + title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true, + })) + + const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })) + const setFreq = (patch: Partial) => setForm((f) => ({ ...f, freq: { ...f.freq, ...patch } })) + const touch = (k: FormField) => setTouched((t) => (t[k] ? t : { ...t, [k]: true })) + + const pickWorkflow = (w: Workflow) => { + if (w.id === form.workflowId) return + // 名字空着、或者还是上次替人填的工作流名:跟着换成新工作流的名字。在这里判、不在 setForm 的回调里改 ref: + // 回调在严格模式下会调两遍 + const follow = !form.name.trim() || form.name === autoName.current + if (follow) autoName.current = w.name + setForm((f) => { + // 换了工作流:新工作流里没有的字段的模板丢掉(同名的留着) + const keep = new Set(graphFields(w.graph).map((x) => x.name)) + const inputs = Object.fromEntries(Object.entries(f.inputs).filter(([k]) => keep.has(k))) + const runClass: ScheduleRunClass = runClassTouched.current ? f.runClass : w.published_version ? 'formal' : 'exploratory' + return { ...f, workflowId: w.id, name: follow ? w.name : f.name, inputs, runClass } + }) + } + + const save = async () => { + if (!canSave) return + const sent = form + setSaving(true) + setSaveError(null) + try { + let saved: Schedule + if (!base) { + saved = await api.schedules.create(createBody(sent)) + } else { + const body = patchBody(sent, initial, base) + if (!body) return + saved = await api.schedules.update(base.id, { ...body, version: version ?? base.version }) + } + const next = formOf(saved) + setBase(saved) + setInitial(next) + // 保存的这段时间里人又改了:只留途中改的那几项(相对刚存下的设置照样算未保存),其余换成刚存下的。 + // 整份留着的话,交上去的替换地址会以完整明文留在框里,下次保存再发一遍 + setForm((cur) => rebaseForm(cur, sent, next)) + setTouched({}) + if (base) toast.ok(T.saved) + onSaved(saved, !base) + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + setSaveError({ message: T.conflict, detail: e.message, conflict: true }) + } else if (isUnsupported(e)) { + setSaveError({ message: T.unsupportedHint }) + } else if (e instanceof ApiError && e.kind === 'http' && e.status >= 400 && e.status < 500) { + setSaveError({ message: e.message, field: e.field, at: e.field ? serverFieldValue(e.field, sent) : null }) + } else { + toast.error(e) + } + } finally { + setSaving(false) + } + } + + const reload = async () => { + if (dirty && !(await confirmDialog({ + title: T.reloadTitle, consequences: T.reloadConsequences, confirmLabel: T.reloadConfirm, danger: true, + }))) return + onReload?.() + } + + const discard = () => { + setForm(initial) + setTouched({}) + setSaveError(null) + } + + // 保存被 422 拒了、服务端指了是哪一项:写在那一项下面;那一项改过之后不再摆着(认不出的项留到下次保存) + const stillSays = !!saveError && (saveError.at == null || !saveError.field || serverFieldValue(saveError.field, form) === saveError.at) + const serverError = (keys: string[]) => (stillSays && saveError?.field && keys.includes(saveError.field) ? saveError.message : undefined) + const shown = (k: FormField) => (touched[k] ? problems[k] : undefined) + const nameError = shown('name') ?? serverError(['name']) + const misfireError = serverError(['misfire_grace_s']) + + return ( +
    { e.preventDefault(); void save() }} + data-schedule-form="" + > +
    +
    + + set({ name: e.target.value })} onBlur={() => touch('name')} data-field="name" /> + + touch('workflow')} /> +
    + + set({ timezone })} + freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} + tzError={previewSays('timezone') ?? serverError(['timezone'])} /> +
    + + + +
    + { runClassTouched.current = true; set({ runClass }) }} /> + set({ inputs })} /> +
    +

    {T.overlap}

    +

    {T.overlapText}

    + + + + {!base && ( + + )} +
    + set({ webhook })} saved={base?.webhook ?? null} + problems={{ ...problems, url: problems.url ?? serverError(['webhook', 'webhook.url']) }} + scheduleId={base?.id ?? null} unsaved={!!changes?.webhook} /> +
    + + {/* 有要保存的、或者有要看的报错时才贴在底部:没改动时一条空的保存栏压在表单上只是挡字 */} +
    + {saveError?.conflict && ( + +
    {saveError.message}
    + {saveError.detail &&
    {saveError.detail}
    } + +
    + )} + {saveError && !saveError.conflict && stillSays && ( + {T.saveFailed}:{saveError.message} + )} +
    + {hint && {hint}} + {base + ? dirty && + : {T.cancel}} + +
    +
    +
    + ) +} + +// --------------------------------------------------------------------------- +// 工作流 +// --------------------------------------------------------------------------- + +function WorkflowField({ form, workflows, state, workflow, base, error, onPick, onBlur }: { + form: ScheduleForm; workflows: Workflow[]; state: string; workflow: Workflow | undefined; base: Schedule | null + error?: string; onPick: (w: Workflow) => void; onBlur: () => void +}) { + // 指向的工作流已经不在列表里(被删了):照实说,不让人以为选的还是它 + const gone = state === 'ok' && !!form.workflowId && !workflow + const message = error ?? (gone ? T.workflowGone(base?.workflow_name ?? form.workflowId) : undefined) + return ( + {T.workflowEmpty} {T.toStudio} : undefined}> + + + ) +} + +// --------------------------------------------------------------------------- +// 频率与时区 +// --------------------------------------------------------------------------- + +function FreqSection({ form, setFreq, setTimezone, freqError, tzError }: { + form: ScheduleForm + setFreq: (patch: Partial) => void + setTimezone: (tz: string) => void + freqError?: string + tzError?: string +}) { + const f = form.freq + const pick = (preset: FreqPreset) => { + if (preset === f.preset) return + // 切到自定义:带上当前预设生成的 cron,在它的基础上改 + if (preset === 'custom') setFreq({ preset, cron: cronOf(f) || f.cron }) + else setFreq({ preset }) + } + const radio = useRadioGroup(FREQ_PRESETS, f.preset, pick) + const errId = 'schedule-freq-error' + // 说明接到出问题的那一项上:每周一天都没选时是星期那一组,否则是这个预设主要的输入框 + const weekdayError = !!freqError && f.preset === 'weekly' && !f.weekdays.length + const errorProps = (primary: boolean) => (primary && freqError && !weekdayError + ? { 'aria-invalid': true as const, 'aria-describedby': errId } : {}) + const zones = useMemo(() => timezoneOptions(form.timezone), [form.timezone]) + const timeInput = ( + + setFreq({ time: e.target.value })} data-freq-time="" {...errorProps(true)} /> + + ) + + return ( +
    +

    {T.freq}

    +
    + {FREQ_PRESETS.map((p) => ( + + ))} +
    + +
    + {f.preset === 'weekly' && ( +
    +
    {T.weekdays}
    +
    + {WEEKDAY_ORDER.map((d) => { + const on = f.weekdays.includes(d) + return ( + + ) + })} +
    +
    + )} + {f.preset === 'monthly' && ( + 28 ? T.monthdayHint : undefined}> + + + )} + {(f.preset === 'daily' || f.preset === 'weekly' || f.preset === 'monthly') && timeInput} + {f.preset === 'hourly' && ( + <> + + + + + setFreq({ minute: e.target.value === '' ? Number.NaN : Number(e.target.value) })} /> + + + )} + {f.preset === 'custom' && ( + + setFreq({ cron: e.target.value })} data-freq-cron="" + {...(freqError ? errorProps(true) : { 'aria-describedby': 'schedule-cron-hint' })} /> + + )} +
    + {f.preset === 'hourly' && !freqError && ( +

    {T.hourlyHint(f.everyHours, f.minute)}

    + )} + {freqError && ( + + )} + + + + +
    + ) +} + +// --------------------------------------------------------------------------- +// 预览 +// --------------------------------------------------------------------------- + +type PreviewState = + | { status: 'idle' } + | { status: 'loading'; data?: SchedulePreview; timezone?: string } + | { status: 'ok'; data: SchedulePreview; timezone: string } + | { status: 'invalid'; message: string; field?: string } + | { status: 'error'; error: unknown } + +/** 预览防抖:停手 400ms 再问,新的一次发出时作废上一次 */ +const PREVIEW_DEBOUNCE_MS = 400 + +/** + * 接下来 5 次触发和渲染出的输入(服务端算,前端不自己解析 cron)。改动中保留上一次的结果并标「正在计算」; + * 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题 + */ +function usePreview(cron: string, timezone: string, template: Record, workflowId: string): PreviewState { + const [state, setState] = useState({ status: 'idle' }) + const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId]) + useEffect(() => { + if (!cron || !timezone) { + setState({ status: 'idle' }) + return + } + setState((s) => (s.status === 'ok' ? { status: 'loading', data: s.data, timezone: s.timezone } + : s.status === 'loading' ? s : { status: 'loading' })) + const ctrl = new AbortController() + const timer = setTimeout(() => { + api.schedules.preview({ + cron, timezone, n: 5, + ...(Object.keys(template).length ? { input_template: template } : {}), + ...(workflowId ? { workflow_id: workflowId } : {}), + }, { signal: ctrl.signal }) + .then((data) => setState({ status: 'ok', data, timezone })) + .catch((e) => { + if ((e as { name?: string })?.name === 'AbortError') return + if (e instanceof ApiError && e.status === 422 && !isUnsupported(e)) { + setState({ status: 'invalid', message: e.message, field: e.field }) + } else { + setState({ status: 'error', error: e }) + } + }) + }, PREVIEW_DEBOUNCE_MS) + return () => { + clearTimeout(timer) + ctrl.abort() + } + // key 已经把四项都编进去了 + }, [key]) + return state +} + +function PreviewBox({ preview }: { preview: PreviewState }) { + const data = preview.status === 'ok' || preview.status === 'loading' ? preview.data : undefined + const tz = preview.status === 'ok' || preview.status === 'loading' ? preview.timezone : undefined + return ( +
    +
    +

    {T.preview}

    + {preview.status === 'loading' && } +
    + {data && ( + <> + {data.description_text &&
    {data.description_text}
    } + {data.next?.length ? ( +
      + {data.next.map((at, i) => { + // 和列表、触发历史一个口径:不带时区后缀的按 UTC 读(new Date 会当成本机时间),读不了的照原文写 + const d = parseServerTime(at) + return ( +
    1. + {i + 1} + +
    2. + ) + })} +
    + ) :

    {T.previewNone}

    } + {tz &&

    {T.previewZone(tz)}

    } + + )} + {!data && preview.status === 'loading' &&

    {T.previewLoading}

    } + {preview.status === 'idle' &&

    {T.previewIdle}

    } + {preview.status === 'invalid' &&

    {preview.message}

    } + {preview.status === 'error' && ( +

    + {isUnsupported(preview.error) ? T.unsupported : `${T.previewFailed}:${errorMessage(preview.error)}`} +

    + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// 运行类别 +// --------------------------------------------------------------------------- + +const RUN_CLASSES: readonly ScheduleRunClass[] = ['formal', 'exploratory'] + +/** + * 运行类别。正式运行要工作流已发布:新建、或者这次改了工作流 / 运行类别时服务端保存就会拒,problem 写在这里、 + * 保存按钮跟着禁用;已保存的定时任务没动这两样、工作流后来撤了发布时保存照常,只提醒到点会发起失败 + */ +function RunClassSection({ value, workflow, problem, error, onChange }: { + value: ScheduleRunClass; workflow: Workflow | undefined + /** 保存前要处理的问题(formProblems 的 runClass) */ + problem?: string + /** 保存被拒、服务端指明是运行类别时的说明 */ + error?: string + onChange: (v: ScheduleRunClass) => void +}) { + const radio = useRadioGroup(RUN_CLASSES, value, onChange) + const unpublished = !!workflow && !workflow.published_version + const describedBy = [problem ? 'schedule-class-problem' : null, error ? 'schedule-class-error' : null].filter(Boolean).join(' ') + return ( +
    +

    {T.runClass}

    +
    + {RUN_CLASSES.map((rc) => { + const on = value === rc + return ( + + ) + })} +
    + {problem ? ( + {problem} + ) : value === 'formal' && unpublished && ( + {T.notPublished} + )} + {value === 'formal' && workflow?.published_version != null && ( +

    {T.formalVersion(workflow.published_version)}

    + )} + {error && ( + + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// 输入 +// --------------------------------------------------------------------------- + +/** 已发布版本的输入字段,按(工作流, 版本)记一份:发布之后那一版就不会再变 */ +const versionFields = new Map() + +type FieldsState = + | { status: 'none' } + | { status: 'loading' } + | { status: 'error'; error: unknown; retry: () => void } + | { status: 'ok'; fields: InputField[]; version: number | null } + +/** + * 这个工作流的输入字段。正式运行到点按当时的发布版本发起,字段取已发布的那一版(和画布可能不同); + * 探索运行用工作流当前保存的内容,字段取它的入口节点 + */ +function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunClass): FieldsState { + const published = runClass === 'formal' && workflow?.published_version ? workflow.published_version : null + const key = workflow && published != null ? `${workflow.id}@${published}` : '' + const [fetched, setFetched] = useState<{ key: string; state: FieldsState } | null>(null) + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + if (!key || !workflow || published == null || versionFields.has(key)) return + let alive = true + setFetched({ key, state: { status: 'loading' } }) + api.workflows.version(workflow.id, published) + .then((v) => { + const list = v.input_fields?.length ? (v.input_fields as InputField[]) : graphFields(v.graph) + versionFields.set(key, list) + if (alive) setFetched({ key, state: { status: 'ok', fields: list, version: published } }) + }) + .catch((error) => { + if (alive) setFetched({ key, state: { status: 'error', error, retry: () => setAttempt((n) => n + 1) } }) + }) + return () => { alive = false } + }, [key, attempt]) + + if (!workflow) return { status: 'none' } + if (published == null) return { status: 'ok', fields: graphFields(workflow.graph), version: null } + const hit = versionFields.get(key) + if (hit) return { status: 'ok', fields: hit, version: published } + return fetched?.key === key ? fetched.state : { status: 'loading' } +} + +/** 输入模板里能用的变量:服务端给,各处共用一份;取不到时为空表,变量按钮照样在,只是说没有可用的 */ +let variablesOnce: Promise | null = null +function useVariables(): ScheduleVariable[] | null { + const [vars, setVars] = useState(null) + useEffect(() => { + let alive = true + variablesOnce ??= api.schedules.variables().then((v) => (Array.isArray(v) ? v : []), (e) => { + variablesOnce = null + throw e + }) + variablesOnce.then((v) => { if (alive) setVars(v) }, () => { if (alive) setVars([]) }) + return () => { alive = false } + }, []) + return vars +} + +const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v)) + +function InputsSection({ workflow, fields, inputs, variables, preview, recheck, error, onChange }: { + workflow: Workflow | undefined + fields: FieldsState + inputs: Record + variables: ScheduleVariable[] | null + preview: PreviewState + /** 保存时服务端会不会核对输入(新建,或者改了工作流、运行类别、输入):会的话必填没填、字段对不上都要先处理 */ + recheck: boolean + error?: string + onChange: (inputs: Record) => void +}) { + const refs = useRef>({}) + const [open, setOpen] = useState(null) + const rendered = preview.status === 'ok' ? preview.data.inputs ?? {} : null + const names = fields.status === 'ok' ? new Set(fields.fields.map((f) => f.name)) : null + // 对不上的字段:只列填了值的(留空的本来就不交) + const stale = names ? Object.keys(inputs).filter((k) => !names.has(k) && inputs[k]?.trim()) : [] + // 正式运行的字段取已发布的版本:画布上有、那一版里没有的,说清是哪一版里没有,不说成工作流里已经删了 + const draft = useMemo(() => new Set(graphFields(workflow?.graph).map((f) => f.name)), [workflow]) + const staleText = (name: string) => { + const reason = fields.status === 'ok' && fields.version != null && draft.has(name) ? T.staleNotInVersion(fields.version) : T.staleField + // 入口声明了字段时服务端只收这些,交上去会被拒;入口什么都没声明时什么键都收 + return recheck && fields.status === 'ok' && fields.fields.length ? `${reason},${T.staleRemove}` : reason + } + + const setValue = (name: string, value: string) => onChange({ ...inputs, [name]: value }) + /** 变量插在光标处(选中了文字就替换它),插完光标落在变量后面 */ + const insert = (name: string, token: string) => { + const el = refs.current[name] + const cur = inputs[name] ?? '' + const start = el?.selectionStart ?? cur.length + const end = el?.selectionEnd ?? cur.length + setValue(name, cur.slice(0, start) + token + cur.slice(end)) + requestAnimationFrame(() => { + el?.focus() + el?.setSelectionRange(start + token.length, start + token.length) + }) + } + + let body + if (fields.status === 'none') body =

    {T.inputsPick}

    + else if (fields.status === 'loading') body = + else if (fields.status === 'error') { + body = ( + + {T.inputsFailed}:{errorMessage(fields.error)} + + + ) + } else if (!fields.fields.length && !stale.length) { + body =

    {T.inputsNone}

    + } else { + body = ( +
    + {fields.fields.map((field) => { + const value = inputs[field.name] ?? '' + const id = `schedule-input-${field.name}` + const varsId = `${id}-vars` + const noDefault = field.required && (field.default == null || shownValue(field.default) === '') + const out = rendered && value.trim() && field.name in rendered ? shownValue(rendered[field.name]) : null + return ( +
    + +
    + { refs.current[field.name] = el }} className="field mono min-w-0 flex-1 basis-48" value={value} + placeholder={field.default != null && shownValue(field.default) !== '' ? T.defaultValue(shownValue(field.default)) : ''} + autoComplete="off" spellCheck={false} onChange={(e) => setValue(field.name, e.target.value)} + aria-describedby={[out != null ? `${id}-rendered` : null, noDefault && !value.trim() ? `${id}-required` : null] + .filter(Boolean).join(' ') || undefined} /> + +
    + {open === field.name && ( +
    + {variables === null ? : variables.length ? variables.map((v) => ( + + )) : {T.variablesEmpty}} +
    + )} + {out != null && ( +
    + {out === '' ? T.renderedEmpty : T.rendered(out)} +
    + )} + {noDefault && !value.trim() && ( +
    + {recheck ? T.requiredFill : T.requiredNoDefault} +
    + )} +
    + ) + })} + {stale.map((name) => ( +
    + {name} + {inputs[name]} + {staleText(name)} + +
    + ))} +
    + ) + } + + return ( +
    +
    +

    {T.inputs}

    +

    {T.inputsHint}

    + {workflow && fields.status === 'ok' && ( +

    + {fields.version != null ? T.inputsFromVersion(fields.version) : T.inputsFromDraft} +

    + )} +
    + {body} + {error &&
    {error}
    } +
    + ) +} diff --git a/frontend/src/pages/schedules/SchedulesPage.tsx b/frontend/src/pages/schedules/SchedulesPage.tsx index cccc3664..753c67da 100644 --- a/frontend/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/src/pages/schedules/SchedulesPage.tsx @@ -1,19 +1,197 @@ -import { CalendarClock } from 'lucide-react' -import { EmptyState, PageHeader } from '../../components/ui' -import { SCHEDULE_TEXT } from '../../lib/terms' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { CalendarClock, Play, Plus, Trash2 } from 'lucide-react' +import { api } from '../../api/client' +import { EmptyState, ErrorState, IconButton, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui' +import { useOnReconnect } from '../../store/catalog' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { Schedule } from '../../types' +import { ClassChip } from '../runs/parts' +import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail' +import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts' /** - * 定时任务(/schedules、/schedules/:id)。现在是占位页,定时任务的工作包补上列表和详情。 + * 定时任务:按设定的周期自动发起工作流运行。 + * + * /schedules 是列表;/schedules/new 新建;/schedules/:id 是一个定时任务的详情(设置、触发历史两个标签, + * ?tab=history 直接打开触发历史)。路由表在 App 里只有带 id、不带 id 两条,「new」在这里认。 */ export function SchedulesPage() { + const { id } = useParams() + if (!id) return + if (id === 'new') return + return +} + +/** 列表每 30 秒原地刷新一次(页面不可见时不刷):到点触发之后,下次运行和上次触发要跟着变 */ +const LIST_REFRESH_MS = 30_000 + +function ScheduleListPage() { + const navigate = useNavigate() + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + /** 每行正在做的事:开关、立即运行、删除各自禁用,不让同一行连点出两个请求 */ + const [busy, setBusy] = useState>({}) + const now = useTicker(30_000) + /** + * 读列表的序号:后发的读胜过先发的读;写操作成功时也加一,作废还在路上的读。那次读可能在写之前就读到了旧值、 + * 却在写的答复之后才回来,落地会把刚换上的那一行(开关的结果、删掉的行)整行盖回去 + */ + const seq = useRef(0) + const hasRows = useRef(false) + const wrote = () => { seq.current++ } + + const load = useCallback(async (opts?: { silent?: boolean }) => { + const n = ++seq.current + try { + const r = await api.schedules.list() + if (n !== seq.current) return + const items = r.items ?? [] + rememberScheduleNames(items) + hasRows.current = true + setRows(items) + setError(null) + } catch (e) { + if (n !== seq.current) return + // 读到过列表之后失败不把整页换成错误,手上的照旧是真数据:定时刷新不吭声,别的(重新载入)弹提示 + if (!hasRows.current) setError(e) + else if (!opts?.silent) toast.error(e) + } + }, []) + useEffect(() => { void load() }, [load]) + useOnReconnect(() => { void load() }) + useEffect(() => { + const t = setInterval(() => { if (!document.hidden) void load({ silent: true }) }, LIST_REFRESH_MS) + return () => clearInterval(t) + }, [load]) + + const replace = (next: Schedule) => setRows((rs) => rs && rs.map((r) => (r.id === next.id ? next : r))) + const act = async (s: Schedule, what: 'toggle' | 'run' | 'delete', fn: () => Promise) => { + if (busy[s.id]) return + setBusy((b) => ({ ...b, [s.id]: what })) + try { + await fn() + } finally { + setBusy((b) => { + const { [s.id]: _, ...rest } = b + return rest + }) + } + } + const toggle = (s: Schedule, enabled: boolean) => act(s, 'toggle', async () => { + const out = await setEnabled(s, enabled) + if (out === 'conflict') await load() + else if (out) { + wrote() + replace(out) + } + }) + const run = (s: Schedule) => act(s, 'run', async () => { + const out = await runNow(s, navigate) + if (!out) return + wrote() + // 只换上次触发:这一行在请求途中可能被开关改过,不拿点下去那一刻的旧值整行盖回去 + setRows((rs) => rs && rs.map((r) => (r.id === s.id ? { ...r, last_fire: out.fire } : r))) + // 下次运行、运行状态以服务端为准,再静默取一遍 + void load({ silent: true }) + }) + const remove = (s: Schedule) => act(s, 'delete', async () => { + if (!(await removeSchedule(s))) return + wrote() + setRows((rs) => rs && rs.filter((r) => r.id !== s.id)) + }) + + const unsupported = isUnsupported(error) + const createButton = ( + + {T.create} + + ) + + let body + if (unsupported) { + body = ( +
    + } title={T.unsupported} body={T.unsupportedHint} offline={false} /> +
    + ) + } else if (rows === null) { + body = error ? void load()} /> : + } else if (!rows.length) { + body = ( +
    + } title={T.emptyTitle} body={T.emptyBody} action={createButton} /> +
    + ) + } else { + body = ( +
      + {rows.map((s) => ( + void toggle(s, v)} onRun={() => void run(s)} onDelete={() => void remove(s)} /> + ))} +
    + ) + } + return ( -
    - } title={SCHEDULE_TEXT.title} subtitle={SCHEDULE_TEXT.hint} /> +
    + } title={T.title} subtitle={T.hint} actions={unsupported ? null : createButton} />
    - {/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */} - } title={SCHEDULE_TEXT.pending} - body={SCHEDULE_TEXT.pendingHint} offline={false} /> +
    {body}
    ) } + +/** + * 一行:名字(链到详情)、工作流和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。 + * 窄屏上右边的按钮整组折到下一行,不挤名字 + */ +function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: { + s: Schedule; now: number; busy?: 'toggle' | 'run' | 'delete' + onToggle: (enabled: boolean) => void; onRun: () => void; onDelete: () => void +}) { + return ( +
  • +
    +
    +
    + + {s.name} + + + {!s.enabled && {T.pausedChip}} +
    +
    + {s.workflow_name} · {s.description_text} +
    +
    +
    + + + + {T.edit} + + } /> +
    +
    +
    +
    + {T.nextRun} + +
    +
    + {T.lastFire} + +
    +
    +
  • + ) +} diff --git a/frontend/src/pages/schedules/WebhookFields.tsx b/frontend/src/pages/schedules/WebhookFields.tsx new file mode 100644 index 00000000..16c8c2c8 --- /dev/null +++ b/frontend/src/pages/schedules/WebhookFields.tsx @@ -0,0 +1,272 @@ +import { useEffect, useState } from 'react' +import { Send } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../../api/client' +import { Field, Notice, Spinner, isComposing, toast, useRadioGroup } from '../../components/ui' +import { errorMessage } from '../../lib/errors' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../types' +import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, webhookTestText, type FormField, type WebhookForm } from './model' + +/** + * Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行链接用的服务访问地址。 + * + * 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」, + * 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置, + * 页面上改了还没保存时不让测,免得测的不是眼前这份 + */ +export function WebhookFields({ value, onChange, saved, problems, scheduleId, unsaved }: { + value: WebhookForm + onChange: (next: WebhookForm) => void + /** 已保存的 Webhook;新建、原来没有时为 null */ + saved: ScheduleWebhook | null + problems: Partial> + /** 已保存的定时任务 id;新建时为 null,不能测试 */ + scheduleId: string | null + /** Webhook 这一块有没保存的改动 */ + unsaved: boolean +}) { + const w = value + const set = (patch: Partial) => onChange({ ...w, ...patch }) + const radio = useRadioGroup(WEBHOOK_FORMATS, w.format, (format) => set({ format })) + const [urlTouched, setUrlTouched] = useState(false) + const [test, setTest] = useState<{ busy: boolean; result?: ScheduleWebhookTest; error?: unknown } | null>(null) + const askUrl = !saved || w.replaceUrl + const urlError = askUrl && (urlTouched || w.url.trim()) ? problems.url : undefined + const testBlocked = !scheduleId || !saved ? T.testNeedSave : unsaved ? T.testDirty : null + + // 设置一改,上一次的测试结果就不代表眼前这份了 + useEffect(() => { setTest((t) => (t?.busy ? t : null)) }, [unsaved]) + + const sendTest = async () => { + if (!scheduleId || testBlocked) return + setTest({ busy: true }) + try { + setTest({ busy: false, result: await api.schedules.testWebhook(scheduleId) }) + } catch (e) { + setTest({ busy: false, error: e }) + } + } + + return ( +
    +

    {T.webhook}

    + + + {!w.on && saved && ( + + {T.webhookRemoving} + + + )} + + {w.on && ( +
    +
    +
    {T.webhookFormat}
    +
    + {WEBHOOK_FORMATS.map((f) => ( + + ))} +
    +
    + + {askUrl ? ( + +
    + set({ url: e.target.value })} onBlur={() => setUrlTouched(true)} data-webhook-url="" /> + {saved && ( + + )} +
    +
    + ) : ( +
    +
    {T.urlSaved}
    +
    + + {saved?.url_hint} + + + +
    +
    {T.urlSavedHint}
    +
    + )} + + + +
    + {T.events} +
    + {SCHEDULE_EVENTS.map((ev) => ( + + ))} +
    + {problems.events &&
    {problems.events}
    } +
    + +
    + + {testBlocked && {testBlocked}} +
    + {test && !test.busy && ( + + {test.result + ? webhookTestText(test.result) + : T.testResult(T.testFailed, null, errorMessage(test.error))} + + )} + + +
    + )} +
    + ) +} + +const toggled = (events: ScheduleEvent[], ev: ScheduleEvent, on: boolean): ScheduleEvent[] => + on ? [...events.filter((x) => x !== ev), ev] : events.filter((x) => x !== ev) + +/** 签名密钥:没设置过时是一个密码框;设置过时只说「已设置」,可以替换或清除 */ +function SecretField({ value: w, saved, onChange, error }: { + value: WebhookForm; saved: ScheduleWebhook | null; onChange: (patch: Partial) => void; error?: string +}) { + const hasSecret = !!saved?.has_secret + if (hasSecret && w.secretMode === 'keep') { + return ( +
    +
    {T.secret}
    +
    + {T.secretSet} + + +
    +
    + ) + } + if (hasSecret && w.secretMode === 'clear') { + return ( +
    +
    {T.secret}
    +
    + {T.secretClearing} + +
    +
    + ) + } + return ( + +
    + onChange({ secret: e.target.value })} data-webhook-secret="" /> + {hasSecret && ( + + )} +
    +
    + ) +} + +/** + * 通知里运行链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口, + * 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉 + */ +function LinkBase() { + const [value, setValue] = useState(undefined) + const [failed, setFailed] = useState(false) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + let alive = true + api.settings.get() + .then((s) => { if (alive) setValue(typeof s?.notify?.public_base_url === 'string' ? s.notify.public_base_url : null) }) + .catch(() => { if (alive) setFailed(true) }) + return () => { alive = false } + }, []) + + const cleaned = draft.trim().replace(/\/+$/, '') + const invalid = !!cleaned && !isHttpUrl(cleaned) + const save = async () => { + if (invalid || busy) return + setBusy(true) + try { + const current = await api.settings.get() + const notify = { ...(current?.notify ?? {}), public_base_url: cleaned || null } + const out = await api.settings.put({ notify }) + setValue(typeof out?.notify?.public_base_url === 'string' ? out.notify.public_base_url : notify.public_base_url) + setFailed(false) + setEditing(false) + toast.ok(cleaned ? T.linkBaseSaved : T.linkBaseCleared) + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + + return ( +
    +
    + {T.linkBase} + + {failed ? T.linkBaseFailed : value === undefined ? : value ? T.linkBaseValue(value) : T.linkBaseUnset} + + {!editing && value !== undefined && ( + + )} +
    + {editing && ( +
    + +
    + setDraft(e.target.value)} + onKeyDown={(e) => { + // 在表单里:回车存这一项,不提交整张表单(输入法组字时的回车不算) + if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } + }} + data-link-base-input="" /> + + +
    +
    +
    + )} +
    + ) +} diff --git a/frontend/src/pages/schedules/WorkflowPicker.tsx b/frontend/src/pages/schedules/WorkflowPicker.tsx new file mode 100644 index 00000000..2b8ac3ae --- /dev/null +++ b/frontend/src/pages/schedules/WorkflowPicker.tsx @@ -0,0 +1,126 @@ +import { useId, useMemo, useState, type KeyboardEvent } from 'react' +import clsx from 'clsx' +import { isComposing } from '../../components/ui' +import { SCHEDULE_TEXT as T, WORKFLOW_STATUS_LABEL } from '../../lib/terms' +import type { Workflow } from '../../types' + +/** 工作流在下拉里的状态:「已发布 v3」「受管 v2」「草稿」 */ +export function workflowStatusText(w: Workflow): string { + if (!w.published_version || !w.status || w.status === 'draft') return T.workflowDraft + return T.workflowVersion(WORKFLOW_STATUS_LABEL[w.status], w.published_version) +} + +/** + * 可搜索的工作流选择(combobox + listbox)。聚焦就列出全部,打字按名称和说明筛;↑↓ 移动、回车选中、Esc 收起。 + * 没展开时输入框里写选中的那个的名字。选项按下时不抢焦点,点选不会先触发输入框失焦把列表收掉 + */ +export function WorkflowPicker({ id, value, workflows, onChange, onBlur, fallbackLabel, invalid, describedBy }: { + id: string + value: string + workflows: Workflow[] + onChange: (w: Workflow) => void + /** 选中的工作流不在列表里(列表还没取回来、或者已被删除)时,输入框里写的名字 */ + fallbackLabel?: string + onBlur?: () => void + invalid?: boolean + describedBy?: string +}) { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [active, setActive] = useState(0) + const listId = `${id}-list` + const optionId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const selected = workflows.find((w) => w.id === value) + + const matches = useMemo(() => { + const q = query.trim().toLowerCase() + const sorted = [...workflows].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) + return q ? sorted.filter((w) => w.name.toLowerCase().includes(q) || (w.description ?? '').toLowerCase().includes(q)) : sorted + }, [workflows, query]) + + const pick = (w: Workflow) => { + onChange(w) + setOpen(false) + setQuery('') + } + const show = () => { + setOpen(true) + setQuery('') + setActive(Math.max(0, matches.findIndex((w) => w.id === value))) + } + const onKeyDown = (e: KeyboardEvent) => { + if (isComposing(e)) return + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + if (!open) { show(); return } + const step = e.key === 'ArrowDown' ? 1 : -1 + setActive((i) => Math.min(Math.max(0, i + step), Math.max(0, matches.length - 1))) + } else if (e.key === 'Enter') { + // 展开着时回车是选中,不是提交表单 + if (open && matches[active]) { + e.preventDefault() + pick(matches[active]) + } + } else if (e.key === 'Escape' && open) { + // 只收起列表,不让外层(弹窗、页面)跟着处理 Esc + e.preventDefault() + e.stopPropagation() + setOpen(false) + setQuery('') + } + } + + return ( +
    + { if (!open) show() }} + onChange={(e) => { + setQuery(e.target.value) + setOpen(true) + setActive(0) + }} + onBlur={() => { + setOpen(false) + setQuery('') + onBlur?.() + }} + onKeyDown={onKeyDown} + /> + {open && ( +
      + {matches.length ? matches.map((w, i) => ( +
    • e.preventDefault()} + onMouseEnter={() => setActive(i)} + onClick={() => pick(w)} + className={clsx('flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-xs', i === active && 'bg-hover')} + > + {w.name} + {workflowStatusText(w)} +
    • + )) :
    • {T.workflowNone}
    • } +
    + )} +
    + ) +} diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts new file mode 100644 index 00000000..30b3ab7d --- /dev/null +++ b/frontend/src/pages/schedules/model.ts @@ -0,0 +1,584 @@ +/** + * 定时任务页的纯函数:频率预设和 cron 互转、按定时任务的时区写时间、表单和请求体互转(含保存回来怎么接)、 + * 输入字段的对照、表单的问题、测试消息的结果。不碰 React、不发请求,scripts/check-schedules.mjs 直接借 vite + * 转译后调用它们。 + * + * 时间一律按定时任务自己的时区写(「每周一 09:00(Asia/Shanghai)」说的就是那个时区的 09:00),本机时区不同时 + * 在悬停提示里另给本机时间:只按本机写,人在外地打开就会以为周报改成了凌晨三点发。 + */ +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { + Schedule, ScheduleCreate, ScheduleEvent, SchedulePatch, ScheduleRunClass, ScheduleWebhookFormat, ScheduleWebhookIn, + ScheduleWebhookTest, Workflow, WorkflowVersion, +} from '../../types' + +// --------------------------------------------------------------------------- +// 频率预设 ⇄ cron +// --------------------------------------------------------------------------- + +export type FreqPreset = 'daily' | 'weekly' | 'monthly' | 'hourly' | 'custom' +export const FREQ_PRESETS: readonly FreqPreset[] = ['daily', 'weekly', 'monthly', 'hourly', 'custom'] + +/** + * 「每隔几小时」只给 24 的约数:*/5 这种在跨天处会断成 4 小时一次(20 点之后下一次是 0 点), + * 名义上的「每 5 小时」就不成立了。要这种的写自定义 cron + */ +export const HOUR_STEPS: readonly number[] = [1, 2, 3, 4, 6, 8, 12] + +/** 星期几按周一在前排(0 是周日,和 cron 一致) */ +export const WEEKDAY_ORDER: readonly number[] = [1, 2, 3, 4, 5, 6, 0] + +export interface Freq { + preset: FreqPreset + /** 每天、每周、每月的时刻,HH:MM(24 小时制) */ + time: string + /** 每周的星期几:0 是周日、1 是周一……6 是周六 */ + weekdays: number[] + /** 每月的几号(1–31) */ + monthday: number + /** 每隔几小时(HOUR_STEPS 之一) */ + everyHours: number + /** 每隔几小时时,在每次的第几分(0–59) */ + minute: number + /** 自定义 cron 的原文 */ + cron: string +} + +export const DEFAULT_FREQ: Freq = { + preset: 'daily', time: '09:00', weekdays: [1], monthday: 1, everyHours: 1, minute: 0, cron: '', +} + +const pad2 = (n: number) => String(n).padStart(2, '0') + +/** 「09:30」→ [9, 30];不是合法的钟点返回 null */ +export function parseTime(text: string): [number, number] | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim()) + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + return h <= 23 && min <= 59 ? [h, min] : null +} + +/** 星期几的那一段:连着三天以上写成区间(1-5),否则逐个列出(0,6);一周七天都选了写 * */ +export function weekdayField(days: number[]): string { + const set = [...new Set(days.map((d) => (d === 7 ? 0 : d)))].filter((d) => d >= 0 && d <= 6).sort((a, b) => a - b) + if (set.length === 7) return '*' + const out: string[] = [] + for (let i = 0; i < set.length;) { + let j = i + while (j + 1 < set.length && set[j + 1] === set[j] + 1) j++ + if (j - i >= 2) out.push(`${set[i]}-${set[j]}`) + else for (let k = i; k <= j; k++) out.push(String(set[k])) + i = j + 1 + } + return out.join(',') +} + +/** + * 预设生成的 cron。填得不完整(钟点没填、每周一天都没选、自定义是空的)时返回空串: + * 这时不预览、不能保存,由 freqProblem 说缺什么,不拿一个猜出来的频率顶上 + */ +export function cronOf(f: Freq): string { + if (f.preset === 'custom') return f.cron.trim().split(/\s+/).filter(Boolean).join(' ') + if (f.preset === 'hourly') { + if (!Number.isInteger(f.minute) || f.minute < 0 || f.minute > 59 || !HOUR_STEPS.includes(f.everyHours)) return '' + return `${f.minute} ${f.everyHours === 1 ? '*' : `*/${f.everyHours}`} * * *` + } + const time = parseTime(f.time) + if (!time) return '' + const [h, m] = time + if (f.preset === 'daily') return `${m} ${h} * * *` + if (f.preset === 'weekly') return f.weekdays.length ? `${m} ${h} * * ${weekdayField(f.weekdays)}` : '' + if (!Number.isInteger(f.monthday) || f.monthday < 1 || f.monthday > 31) return '' + return `${m} ${h} ${f.monthday} * *` +} + +/** 一段 cron 里的一个整数(不带步长、区间),超出范围或不是整数时返回 null */ +function intField(text: string, lo: number, hi: number): number | null { + if (!/^\d{1,2}$/.test(text)) return null + const n = Number(text) + return n >= lo && n <= hi ? n : null +} + +/** 星期几那一段(0–7 的数字、逗号列表、区间)→ 排好序的星期几;写了名字、步长之类的返回 null,交给自定义 */ +function parseWeekdays(text: string): number[] | null { + const out = new Set() + for (const part of text.split(',')) { + const range = /^(\d)-(\d)$/.exec(part) + if (range) { + const a = Number(range[1]) + const b = Number(range[2]) + if (a > b || b > 7) return null + for (let d = a; d <= b; d++) out.add(d === 7 ? 0 : d) + continue + } + const d = intField(part, 0, 7) + if (d == null) return null + out.add(d === 7 ? 0 : d) + } + return out.size ? [...out].sort((a, b) => a - b) : null +} + +/** + * 已有的 cron 落回预设:编辑时照它选中「每周」、勾上周一。认不出的(写了月份、名字、步长、 + * 「每隔几小时」不是 24 的约数)一律算自定义,原文照放,不改写别人写的 cron + */ +export function freqOf(cron: string): Freq { + const parts = cron.trim().split(/\s+/).filter(Boolean) + const base: Freq = { ...DEFAULT_FREQ, cron: parts.join(' ') } + const custom: Freq = { ...base, preset: 'custom' } + if (parts.length !== 5) return custom + const [mi, ho, dom, mon, dow] = parts + if (mon !== '*') return custom + const minute = intField(mi, 0, 59) + if (minute == null) return custom + const hour = intField(ho, 0, 23) + if (hour != null) { + const time = `${pad2(hour)}:${pad2(minute)}` + if (dom === '*' && dow === '*') return { ...base, preset: 'daily', time } + if (dom === '*') { + const weekdays = parseWeekdays(dow) + return weekdays ? { ...base, preset: 'weekly', time, weekdays } : custom + } + if (dow === '*') { + const monthday = intField(dom, 1, 31) + return monthday != null ? { ...base, preset: 'monthly', time, monthday } : custom + } + return custom + } + if (dom !== '*' || dow !== '*') return custom + if (ho === '*') return { ...base, preset: 'hourly', everyHours: 1, minute } + const step = /^\*\/(\d{1,2})$/.exec(ho) + if (step && HOUR_STEPS.includes(Number(step[1]))) return { ...base, preset: 'hourly', everyHours: Number(step[1]), minute } + return custom +} + +// --------------------------------------------------------------------------- +// 时区与时间 +// --------------------------------------------------------------------------- + +/** 本机时区;浏览器说不出来时按 UTC */ +export function browserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } +} + +/** 常用时区,下拉里排在前面。城市名在 SCHEDULE_TEXT.timezoneLabel,偏移按当前日期另算(夏令时会变) */ +export const COMMON_TIMEZONES: readonly string[] = [ + 'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Taipei', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Kolkata', + 'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'America/New_York', 'America/Chicago', + 'America/Los_Angeles', 'UTC', +] + +/** + * 时区下拉的选项:本机时区、正在用的时区排最前(不在常用表里也列上),再接常用的,不重复。 + * label 写「Asia/Shanghai(北京,UTC+08:00)」,本机的另标「本机」 + */ +export function timezoneOptions(current: string, local: string = browserTimezone(), now: Date = new Date()): + { tz: string; label: string }[] { + const seen = new Set() + const out: { tz: string; label: string }[] = [] + for (const tz of [local, current, ...COMMON_TIMEZONES]) { + if (!tz || seen.has(tz)) continue + seen.add(tz) + const notes = [T.timezoneLabel[tz], offsetLabel(tz, now), tz === local ? T.timezoneLocal : ''].filter(Boolean) + out.push({ tz, label: notes.length ? `${tz}(${notes.join(',')})` : tz }) + } + return out +} + +export interface ZonedParts { + year: number + month: number + day: number + hour: number + minute: number + /** 0 是周日 */ + weekday: number +} + +const formatters = new Map() +const WEEKDAY_EN: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } + +/** 某个时刻在某个时区里的年月日时分和星期。时区名浏览器不认时返回 null */ +export function zonedParts(d: Date, tz: string): ZonedParts | null { + if (Number.isNaN(d.getTime())) return null + let f = formatters.get(tz) + if (f === undefined) { + try { + // 用 en-US 取各段的数字和英文星期缩写,再自己拼中文:各浏览器的 zh-CN 写法不完全一样 + f = new Intl.DateTimeFormat('en-US', { + timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', + weekday: 'short', hourCycle: 'h23', + }) + } catch { + f = null + } + formatters.set(tz, f) + } + if (!f) return null + const p: Record = {} + for (const part of f.formatToParts(d)) p[part.type] = part.value + const parts = { + year: Number(p.year), month: Number(p.month), day: Number(p.day), + // 个别引擎在 h23 下仍把午夜写成 24 + hour: Number(p.hour) % 24, minute: Number(p.minute), weekday: WEEKDAY_EN[p.weekday] ?? -1, + } + return Object.values(parts).every((v) => Number.isFinite(v) && v >= 0) ? parts : null +} + +/** 这个时区此刻相对 UTC 的偏移:「UTC+08:00」「UTC-04:00」「UTC」 */ +export function offsetLabel(tz: string, d: Date = new Date()): string { + const p = zonedParts(d, tz) + if (!p) return '' + const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) + const minutes = Math.round((wall - Math.floor(d.getTime() / 60_000) * 60_000) / 60_000) + if (minutes === 0) return 'UTC' + const abs = Math.abs(minutes) + return `UTC${minutes > 0 ? '+' : '-'}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}` +} + +/** + * 按时区写一个时刻。full:「2026-10-06 周一 09:00」;short:同一年省掉年份「10/6 周一 09:00」, + * 跨年写「2027/1/4 周一 09:00」。时区名浏览器不认时按本机时区写 + */ +export function formatZoned(d: Date, tz: string, mode: 'full' | 'short' = 'short', now: Date = new Date()): string { + const p = zonedParts(d, tz) ?? zonedParts(d, browserTimezone()) + if (!p) return '—' + const hm = `${pad2(p.hour)}:${pad2(p.minute)}` + const wd = T.weekdayShort[p.weekday] + if (mode === 'full') return `${p.year}-${pad2(p.month)}-${pad2(p.day)} ${wd} ${hm}` + const thisYear = (zonedParts(now, tz) ?? p).year + const md = `${p.month}/${p.day}` + return `${p.year === thisYear ? md : `${p.year}/${md}`} ${wd} ${hm}` +} + +/** + * 距离一个将来时刻还有多久:「不到 1 分钟后」「12 分钟后」「3 小时后」「2 天后」。先取整再判档,59.6 分钟 + * 写「1 小时后」而不是「60 分钟后」。已经过了的返回 null,由调用方照实说(调度可能没开、或者正在发起) + */ +export function formatUntil(target: Date, now: number): string | null { + const diff = target.getTime() - now + if (diff < 0) return null + if (diff < 60_000) return T.until.soon + const m = Math.round(diff / 60_000) + if (m < 60) return T.until.minutes(m) + const h = Math.round(diff / 3_600_000) + if (h < 24) return T.until.hours(h) + return T.until.days(Math.round(diff / 86_400_000)) +} + +/** 补发宽限的写法:「5 分钟」「1 小时」「1 天」,凑不成整数的按秒写 */ +export function graceLabel(seconds: number): string { + if (seconds > 0 && seconds % 86_400 === 0) return T.span.day(seconds / 86_400) + if (seconds > 0 && seconds % 3600 === 0) return T.span.hour(seconds / 3600) + if (seconds > 0 && seconds % 60 === 0) return T.span.minute(seconds / 60) + return T.span.second(seconds) +} + +/** + * 补发宽限的可选值。不给 0:调度 15 秒一拍,到点的触发总要晚上几秒,宽限为 0 等于每次都算错过、全部跳过 + */ +export const GRACE_OPTIONS: readonly number[] = [300, 3600, 21_600, 86_400] +export const DEFAULT_GRACE_S = 3600 + +// --------------------------------------------------------------------------- +// 表单 ⇄ 请求体 +// --------------------------------------------------------------------------- + +export const WEBHOOK_FORMATS: readonly ScheduleWebhookFormat[] = ['generic', 'feishu', 'wecom', 'dingtalk'] +export const SCHEDULE_EVENTS: readonly ScheduleEvent[] = ['finished', 'failed', 'awaiting_approval'] + +export interface WebhookForm { + on: boolean + format: ScheduleWebhookFormat + /** 新填的地址。已保存过地址、又没点「替换」时不用它 */ + url: string + /** 已保存过地址时:true 表示换成上面新填的 */ + replaceUrl: boolean + secret: string + /** 已设置过密钥时:keep 保留、replace 换成上面新填的、clear 清除。没设置过时只看 secret 填没填 */ + secretMode: 'keep' | 'replace' | 'clear' + events: ScheduleEvent[] +} + +export interface ScheduleForm { + name: string + workflowId: string + freq: Freq + timezone: string + runClass: ScheduleRunClass + /** 字段名 → 模板,留空的字段不交(按工作流的默认值) */ + inputs: Record + /** 只在新建时用:修改时启用、暂停走页头的开关,立刻生效 */ + enabled: boolean + misfireGraceS: number + webhook: WebhookForm +} + +/** 表单初值:新建时用默认值(时区取本机),修改时照读到的定时任务 */ +export function formOf(s: Schedule | null, timezone: string = browserTimezone()): ScheduleForm { + if (!s) { + return { + name: '', workflowId: '', freq: { ...DEFAULT_FREQ }, timezone, runClass: 'formal', inputs: {}, enabled: true, + misfireGraceS: DEFAULT_GRACE_S, + webhook: { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] }, + } + } + return { + name: s.name, + workflowId: s.workflow_id, + freq: freqOf(s.cron), + timezone: s.timezone, + runClass: s.run_class, + inputs: { ...(s.input_template ?? {}) }, + enabled: s.enabled, + misfireGraceS: s.misfire_grace_s, + webhook: s.webhook + ? { on: true, format: s.webhook.format, url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...s.webhook.events] } + : { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] }, + } +} + +/** 交上去的输入模板:只留填了的字段(只有空白的也算没填),留空的按工作流的默认值 */ +export function templateOf(inputs: Record): Record { + return Object.fromEntries(Object.entries(inputs).filter(([, v]) => typeof v === 'string' && v.trim() !== '')) +} + +/** 事件按固定顺序排,勾选的先后不影响请求体,也不让「改过又改回」算成改动 */ +const sortEvents = (events: ScheduleEvent[]) => SCHEDULE_EVENTS.filter((e) => events.includes(e)) + +export function createBody(f: ScheduleForm): ScheduleCreate { + const w = f.webhook + return { + name: f.name.trim(), + workflow_id: f.workflowId, + cron: cronOf(f.freq), + timezone: f.timezone, + run_class: f.runClass, + input_template: templateOf(f.inputs), + enabled: f.enabled, + overlap: 'skip', + misfire_grace_s: f.misfireGraceS, + webhook: w.on + ? { format: w.format, url: w.url.trim(), ...(w.secret ? { secret: w.secret } : {}), events: sortEvents(w.events) } + : null, + } +} + +const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) +const sortedEntries = (o: Record) => Object.entries(o).sort(([a], [b]) => a.localeCompare(b)) + +/** + * 修改时交上去的字段(不含 version):拿表单和打开时的表单比,只带改了的,什么都没改返回 null。和表单初值比、 + * 不和服务端的原文比:cron 落回预设再生成,写法可能和原文不同(0-6 变成 *),人没动它就不该算改了频率。 + * + * Webhook 按约定:地址省略表示不改、空串表示删除;密钥省略表示不改、空串表示清除。要交 Webhook 时格式和事件 + * 总是一起交上,免得服务端把没写的当成清空 + */ +export function patchBody(f: ScheduleForm, initial: ScheduleForm, saved: Schedule): Omit | null { + const out: Omit = {} + if (f.name.trim() !== initial.name.trim()) out.name = f.name.trim() + if (f.workflowId !== initial.workflowId) out.workflow_id = f.workflowId + if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq) + if (f.timezone !== initial.timezone) out.timezone = f.timezone + if (f.runClass !== initial.runClass) out.run_class = f.runClass + if (!sameJson(sortedEntries(templateOf(f.inputs)), sortedEntries(templateOf(initial.inputs)))) { + out.input_template = templateOf(f.inputs) + } + if (f.misfireGraceS !== initial.misfireGraceS) out.misfire_grace_s = f.misfireGraceS + const webhook = webhookPatch(f.webhook, initial.webhook, saved) + if (webhook) out.webhook = webhook + return Object.keys(out).length ? out : null +} + +function webhookPatch(w: WebhookForm, before: WebhookForm, saved: Schedule): ScheduleWebhookIn | null { + const events = sortEvents(w.events) + if (!saved.webhook) { + // 原来没有:打开了才交,地址必填 + if (!w.on) return null + return { format: w.format, url: w.url.trim(), ...(w.secret ? { secret: w.secret } : {}), events } + } + if (!w.on) return { format: saved.webhook.format, url: '', events: sortEvents(saved.webhook.events) } + const out: ScheduleWebhookIn = { format: w.format, events } + if (w.replaceUrl && w.url.trim()) out.url = w.url.trim() + if (saved.webhook.has_secret) { + if (w.secretMode === 'clear') out.secret = '' + else if (w.secretMode === 'replace' && w.secret) out.secret = w.secret + } else if (w.secret) { + out.secret = w.secret + } + const changed = out.url != null || out.secret != null || w.format !== before.format + || !sameJson(events, sortEvents(before.events)) + return changed ? out : null +} + +/** + * 保存回来时表单怎么接:保存途中人没再动过的项换成刚存下的设置,动过的留着(相对刚存下的设置照样算未保存)。 + * Webhook 逐项比:途中只改了事件时,交上去的替换地址、新密钥照样换成「已保存」,不让含令牌的完整地址留在框里、 + * 下次保存再发一遍 + */ +export function rebaseForm(cur: ScheduleForm, sent: ScheduleForm, saved: ScheduleForm): ScheduleForm { + if (cur === sent) return saved + const pick = (k: K) => (sameJson(cur[k], sent[k]) ? saved[k] : cur[k]) + const hook = (k: K) => (sameJson(cur.webhook[k], sent.webhook[k]) ? saved.webhook[k] : cur.webhook[k]) + // 地址和「替换」、密钥和它的处理方式各算一组:只换半组会留下「要替换却没填地址」这种拼不上的状态 + const urlSame = cur.webhook.url === sent.webhook.url && cur.webhook.replaceUrl === sent.webhook.replaceUrl + const secretSame = cur.webhook.secret === sent.webhook.secret && cur.webhook.secretMode === sent.webhook.secretMode + return { + name: pick('name'), + workflowId: pick('workflowId'), + freq: pick('freq'), + timezone: pick('timezone'), + runClass: pick('runClass'), + inputs: pick('inputs'), + enabled: pick('enabled'), + misfireGraceS: pick('misfireGraceS'), + webhook: { + on: hook('on'), + format: hook('format'), + url: urlSame ? saved.webhook.url : cur.webhook.url, + replaceUrl: urlSame ? saved.webhook.replaceUrl : cur.webhook.replaceUrl, + secret: secretSame ? saved.webhook.secret : cur.webhook.secret, + secretMode: secretSame ? saved.webhook.secretMode : cur.webhook.secretMode, + events: hook('events'), + }, + } +} + +/** + * 服务端 422 指的那一项在表单里的值,拿来比:人改过那一项就不再摆着那句说明(改回原值时说明又出来)。 + * 运行类别、输入连带工作流一起算:换了工作流,「没发布」「字段对不上」也就不一定成立了。认不出的项返回 null + */ +export function serverFieldValue(field: string, f: ScheduleForm): string | null { + switch (field) { + case 'name': return f.name.trim() + case 'workflow_id': return f.workflowId + case 'cron': return cronOf(f.freq) + case 'timezone': return f.timezone + case 'run_class': return JSON.stringify([f.runClass, f.workflowId]) + case 'input_template': return JSON.stringify([sortedEntries(templateOf(f.inputs)), f.workflowId, f.runClass]) + case 'misfire_grace_s': return String(f.misfireGraceS) + case 'webhook': + case 'webhook.url': return JSON.stringify(f.webhook) + default: return null + } +} + +// --------------------------------------------------------------------------- +// 输入字段 +// --------------------------------------------------------------------------- + +export type InputField = { name: string; required?: boolean; description?: string; default?: unknown } + +/** 工作流(或某个版本)入口节点声明的输入字段 */ +export function graphFields(graph: WorkflowVersion['graph'] | Workflow['graph'] | undefined): InputField[] { + const entry = graph?.nodes?.find((n) => n.type === 'input') + return ((entry?.data?.config?.fields ?? []) as InputField[]).filter((f) => f && typeof f.name === 'string' && f.name) +} + +const blankDefault = (v: unknown) => v == null || (typeof v === 'string' ? v : JSON.stringify(v)) === '' + +/** + * 输入模板对照这次会用到的字段(正式运行是已发布的那一版,探索运行是画布)。照服务端保存时的核对: + * unknown 是填了值、但不在这些字段里的(交上去会被拒);missing 是必填、没有默认值、又没填的。 + * 只有空白的不交,不算。入口没声明字段的工作流什么键都收,两样都不报 + */ +export function inputProblems(inputs: Record, fields: readonly InputField[]): { unknown: string[]; missing: string[] } { + if (!fields.length) return { unknown: [], missing: [] } + const tpl = templateOf(inputs) + const names = new Set(fields.map((x) => x.name)) + return { + unknown: Object.keys(tpl).filter((k) => !names.has(k)), + missing: fields.filter((x) => x.required && blankDefault(x.default) && !(x.name in tpl)).map((x) => x.name), + } +} + +// --------------------------------------------------------------------------- +// 表单的问题 +// --------------------------------------------------------------------------- + +export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events' + +/** 判表单问题时要的、表单之外的事 */ +export interface FormContext { + /** 选中的工作流的发布版本:null 是没发布;undefined 是还不知道(工作流列表没取回来),不判 */ + published?: number | null + /** 这次会用到的输入字段;还没读到时不判 */ + fields?: readonly InputField[] + /** + * 保存时服务端会不会核对工作流、运行类别和输入:新建总会;修改只在改了这三样之一时才会(只改名、停用不查, + * 工作流后来撤了发布也能先改名、先停下来)。不给按新建、修改区分 + */ + recheck?: boolean +} + +/** + * 表单哪几项还不能保存,值是写在那一项下面的话。saved 是修改中的定时任务(新建时为 null)。 + * 服务端保存时就会拒的组合(正式运行 + 工作流没发布、必填没填、字段对不上)在这里拦下,不让人点了保存才知道 + */ +export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormContext = {}): Partial> { + const out: Partial> = {} + if (!f.name.trim()) out.name = T.problem.name + if (!f.workflowId) out.workflow = T.problem.workflow + const freq = freqProblem(f.freq) + if (freq) out.freq = freq + if (!f.timezone) out.timezone = T.problem.timezone + if (f.workflowId && (ctx.recheck ?? !saved)) { + if (f.runClass === 'formal' && ctx.published === null) out.runClass = T.problem.notPublished + if (ctx.fields) { + const { unknown, missing } = inputProblems(f.inputs, ctx.fields) + if (unknown.length) out.inputs = T.problem.inputsUnknown(unknown.join('、')) + else if (missing.length) out.inputs = T.problem.inputsMissing(missing.join('、')) + } + } + const w = f.webhook + if (w.on) { + const needUrl = !saved?.webhook || w.replaceUrl + if (needUrl && !w.url.trim()) out.url = T.problem.url + else if (needUrl && !isHttpUrl(w.url)) out.url = T.problem.urlInvalid + if (saved?.webhook?.has_secret && w.secretMode === 'replace' && !w.secret) out.secret = T.problem.secret + if (!w.events.length) out.events = T.problem.events + } + return out +} + +/** 频率填得哪里不完整;完整时返回 null(cron 合不合法由服务端的预览判) */ +export function freqProblem(f: Freq): string | null { + if (f.preset === 'custom') { + const n = f.cron.trim().split(/\s+/).filter(Boolean).length + if (!n) return T.problem.cronEmpty + return n === 5 ? null : T.problem.cronFields + } + if (f.preset === 'hourly') return cronOf(f) ? null : T.problem.minute + if (!parseTime(f.time)) return T.problem.time + if (f.preset === 'weekly' && !f.weekdays.length) return T.problem.weekdays + return null +} + +/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook、服务访问地址都用它) */ +export function isHttpUrl(text: string): boolean { + return /^https?:\/\/\S+$/i.test(text.trim()) +} + +// --------------------------------------------------------------------------- +// 测试消息的结果 +// --------------------------------------------------------------------------- + +/** + * 测试消息的结果写成一句:结局(已送达 / 发送失败)、状态码、服务端的说明各说一遍。服务端的 message 两种写法都接得住: + * 只是补充说明(「请到接收端确认已收到」「open.feishu.cn 返回 HTTP 404:…」)时接在结局后面;已经是整句 + * (「测试消息已送达(HTTP 200)」「推送失败:…」)时不再套一层。说明里写过状态码就不在结局后面再写 + */ +export function webhookTestText(r: Pick & { message?: string | null }): string { + const head = r.ok ? T.testOk : T.testFailed + let detail = (r.message ?? '').trim() + if (detail.startsWith(head)) return detail + // 失败时开头那半句「推送失败:」「发送失败:」和结局重复 + if (!r.ok) detail = detail.replace(/^(?:测试消息)?(?:推送|发送)失败[::]\s*/, '') + const status = r.http_status != null && !detail.includes(`HTTP ${r.http_status}`) ? r.http_status : null + return T.testResult(head, status, detail) +} diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx new file mode 100644 index 00000000..bd0e14f0 --- /dev/null +++ b/frontend/src/pages/schedules/parts.tsx @@ -0,0 +1,293 @@ +import { useEffect, useSyncExternalStore, type MouseEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { CalendarClock } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api } from '../../api/client' +import { StatusPill, confirmDialog, toast } from '../../components/ui' +import { formatDateTime, parseServerTime } from '../../lib/format' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types' +import { browserTimezone, formatUntil, formatZoned } from './model' + +// ------------------------------------------------------------------------- +// 启用开关 +// ------------------------------------------------------------------------- + +/** + * 启用 / 暂停的开关(role=switch,空格、回车切换)。点了就 PATCH、立刻生效,不等表单保存: + * 暂停是「别再到点发起了」,拖到保存时才生效就晚了。可见的字固定写「启用」,开没开看开关本身和 aria-checked + */ +export function EnableSwitch({ checked, busy, label, onChange }: { + checked: boolean; busy?: boolean; label: string; onChange: (next: boolean) => void +}) { + return ( + + ) +} + +// ------------------------------------------------------------------------- +// 时间 +// ------------------------------------------------------------------------- + +/** + * 按定时任务的时区写一个时刻;relative 时前面加「3 小时后」。悬停写完整时间和时区,本机时区不同时另给本机时间: + * 只写一个钟点,人在别的时区打开会把它当本地时间 + */ +export function ZonedTime({ date, tz, now, relative = false, className }: { + date: Date; tz: string; now: number; relative?: boolean; className?: string +}) { + const abs = formatZoned(date, tz, 'short', new Date(now)) + const title = [T.zoneTitle(formatZoned(date, tz, 'full'), tz), + tz !== browserTimezone() ? T.localTitle(formatDateTime(date)) : null].filter(Boolean).join('\n') + const rel = relative ? formatUntil(date, now) ?? T.nextOverdue : null + return ( + + ) +} + +/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */ +export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) { + if (!schedule.enabled) return {T.nextPaused} + const d = parseServerTime(schedule.next_run_at) + if (!d) return {T.nextNone} + return +} + +// ------------------------------------------------------------------------- +// 触发 +// ------------------------------------------------------------------------- + +/** 触发结局的颜色:发起了是安静的完成色,跳过是琥珀,发起失败是红 */ +const FIRE_TONE: Record = { + started: 'var(--st-done)', + skipped_overlap: 'var(--st-waiting)', + skipped_misfire: 'var(--st-waiting)', + failed_to_start: 'var(--st-failed)', +} + +/** 触发结局的小标签。跳过、发起失败铺淡底,悬停写原因(没有原因写这种结局的一般说明) */ +export function FireStatusChip({ fire }: { fire: ScheduleFire }) { + const color = FIRE_TONE[fire.status] ?? 'var(--text-dim)' + return ( + + {T.fireStatus[fire.status] ?? fire.status} + + ) +} + +/** + * 触发类型:定时 / 手动 / 补发。服务端把错过的触发都记成 kind=misfire:宽限内补发的那一次照常有结局,没有补发的 + * 是 skipped_misfire。后者没有「补发」这回事,按「定时」写,结局由旁边的「错过已跳过」说。没有补发有两种原因(超出 + * 补发宽限;宽限内恢复时积压的那几次不补),悬停不替服务端认定是哪一种,否则会和本条记的原因自相矛盾 + */ +export function FireKindChip({ fire }: { fire: ScheduleFire }) { + const missed = fire.kind === 'misfire' && fire.status === 'skipped_misfire' + const kind = missed ? 'scheduled' : fire.kind + return ( + + {T.fireKind[kind] ?? kind} + + ) +} + +/** 列表里的「上次触发」:发起了运行的链到那次运行、写运行现在的状态;跳过、发起失败的写结局 */ +export function LastFire({ fire, tz, now }: { fire: ScheduleFire | null; tz: string; now: number }) { + if (!fire) return {T.neverFired} + const d = parseServerTime(fire.fire_at) + const when = d ? : null + if (fire.run_id) { + return ( + + {fire.run_status ? : } + {when} + + ) + } + return ( + + + {when} + + ) +} + +// ------------------------------------------------------------------------- +// 列表行和详情页头共用的操作 +// ------------------------------------------------------------------------- + +/** + * 启用 / 暂停:带读到的版本 PATCH,成功交回新的定时任务。版本不符(409)时说清是别处改的、返回 'conflict', + * 由调用方重新载入;别的错误弹提示、返回 null。409 的原话是写给表单保存的(「请刷新后再保存」),开关不照搬, + * 放进详情 + */ +export async function setEnabled(s: Schedule, enabled: boolean): Promise { + try { + const next = await api.schedules.update(s.id, { enabled, version: s.version }) + toast.ok(enabled ? T.enabledOk(s.name) : T.pausedOk(s.name)) + return next + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + toast.warn(T.toggleConflict(s.name), e.message ? { detail: e.message } : undefined) + return 'conflict' + } + toast.error(e) + return null + } +} + +/** + * 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。返回这次触发(出错时 null), + * 调用方拿它更新「上次触发」和触发历史 + */ +export async function runNow(s: Schedule, navigate: (to: string) => void): Promise { + try { + const r = await api.schedules.run(s.id) + const runId = r.run_id ?? r.fire?.run_id ?? null + if (runId) { + toast.ok(T.runStarted, { action: { label: T.openRun, onClick: () => navigate(`/runs/${encodeURIComponent(runId)}`) } }) + } else if (r.fire?.status === 'skipped_overlap' || r.fire?.status === 'skipped_misfire') { + toast.warn(r.fire.status === 'skipped_overlap' ? T.runSkipped : T.fireStatus.skipped_misfire, + r.fire.reason ? { detail: r.fire.reason } : undefined) + } else { + toast.error(T.runFailed(r.fire?.reason ?? null)) + } + return r + } catch (e) { + toast.error(e) + return null + } +} + +/** 删除:先用站内对话框确认、写清后果。删掉了返回 true */ +export async function removeSchedule(s: Schedule): Promise { + const ok = await confirmDialog({ + title: T.deleteTitle(s.name), consequences: T.deleteConsequences, confirmLabel: T.deleteConfirm, danger: true, + }) + if (!ok) return false + try { + await api.schedules.remove(s.id) + rememberScheduleNames([], [s.id]) + toast.ok(T.deleted(s.name)) + return true + } catch (e) { + toast.error(e) + return false + } +} + +/** 老服务端没有定时任务接口:404 且没有机读码(有码的 404 是服务端自己说的「不存在」) */ +export const isUnsupported = (e: unknown) => e instanceof ApiError && e.status === 404 && !e.code + && /^not found$/i.test(String(e.detail ?? '')) + +// ------------------------------------------------------------------------- +// 记录页的「定时」标记 +// ------------------------------------------------------------------------- + +/** + * 定时任务的名字,给记录页的「定时」标记写悬停提示。模块里记一份:定时任务页读过列表就顺手记下,记录页遇到 + * 不认识的 id 再取一次列表(一分钟内最多一次)。取不到(老服务端、暂时连不上)不要紧,标记照样显示,只是不写名字 + */ +const names = new Map() +let fetchedAt = 0 +let inflight: Promise | null = null +let namesVersion = 0 +const nameListeners = new Set<() => void>() +const NAMES_RETRY_MS = 60_000 + +const emitNames = () => { + namesVersion++ + nameListeners.forEach((l) => l()) +} + +/** 记下读到的定时任务名字(gone 里的是刚删掉的) */ +export function rememberScheduleNames(items: Pick[], gone: string[] = []) { + for (const s of items) names.set(s.id, s.name) + for (const id of gone) names.delete(id) + emitNames() +} + +function ensureNames(ids: string[]) { + if (inflight || !ids.some((id) => !names.has(id)) || Date.now() - fetchedAt < NAMES_RETRY_MS) return + inflight = api.schedules.list({ timeoutMs: 10_000 }) + .then((r) => { for (const s of r.items ?? []) names.set(s.id, s.name) }) + .catch(() => { /* 取不到就不写名字 */ }) + .finally(() => { + inflight = null + fetchedAt = Date.now() + emitNames() + }) +} + +/** 这些定时任务的名字(id → 名字)。没记过的去取一次,取回来后重渲染 */ +export function useScheduleNames(ids: (string | null | undefined)[]): ReadonlyMap { + useSyncExternalStore( + (l) => { nameListeners.add(l); return () => { nameListeners.delete(l) } }, + () => namesVersion, + ) + const key = [...new Set(ids.filter((x): x is string => !!x))].sort().join(',') + useEffect(() => { if (key) ensureNames(key.split(',')) }, [key]) + return names +} + +/** + * 运行名字旁的「定时」标记:悬停写是哪个定时任务,点一下去那个定时任务。记录页的整行是打开运行的按钮, + * 这里拦住冒泡,只去定时任务、不打开运行。 + * + * 按钮里放不了可聚焦的链接(读屏会把按钮里的内容压平,Tab 也到不了),所以键盘到不了这个标记:读屏改念 + * 「由定时任务「X」发起」,至少在整行的名字里听到来源。键盘可达的入口要放到运行详情的页头,或者把整行改成 + * 「打开运行」按钮和这个链接并排,都不在本页的文件里,见交付说明 + */ +export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: string | null }) { + const navigate = useNavigate() + const go = (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + navigate(`/schedules/${encodeURIComponent(scheduleId)}`) + } + return ( + + {T.runTag} + {T.runTagSr(name)} + + ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 39866717..fcc0281b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3167,3 +3167,161 @@ export interface WhatIfResult { unsupported: WhatIfUnsupported[] affected_cards: WhatIfAffectedCard[] } + +// --------------------------------------------------------------------------- +// 定时任务(/api/schedules,页面在 pages/schedules)。前后端按同一份接口约定,字段名和形状不各自发挥;时间一律是 +// ISO 字符串(UTC),按定时任务自己的时区显示 +// --------------------------------------------------------------------------- + +/** + * 运行列表里的定时来源:发起它的定时任务,手动发起的是 null,老服务端不给。写在这里而不是上面 Run 的定义里: + * 同名接口会合并,定时任务的类型集中在文件末尾这一段,不和别的工作包改同一处 + */ +export interface Run { + schedule_id?: string | null +} + +export type ScheduleRunClass = 'formal' | 'exploratory' +/** Webhook 消息的格式:通用 JSON、飞书 / 企业微信 / 钉钉的群机器人 */ +export type ScheduleWebhookFormat = 'generic' | 'feishu' | 'wecom' | 'dingtalk' +/** 订阅的事件:运行结束、运行失败、停在人工审批上(只在真有待审批时发) */ +export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval' +/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发 */ +export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire' +/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */ +export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start' + +/** 读出来的 Webhook 设置。完整地址服务端加密保存、不再返回,只露主机和末 4 位 */ +export interface ScheduleWebhook { + format: ScheduleWebhookFormat + /** 如 open.feishu.cn/…ab12 */ + url_hint: string + has_secret: boolean + events: ScheduleEvent[] +} + +/** 一次触发发出的一条通知 */ +export interface ScheduleFireNotice { + event: ScheduleEvent + status: 'ok' | 'failed' + at: string +} + +/** 一次触发(触发历史的一行) */ +export interface ScheduleFire { + id: string + /** 计划触发的时间;手动运行是按下的那一刻 */ + fire_at: string + kind: ScheduleFireKind + status: ScheduleFireStatus + run_id: string | null + /** 发起的那次运行现在的状态(同 Run.status) */ + run_status: RunStatus | null + /** 跳过、发起失败的原因(中文) */ + reason: string | null + notified: ScheduleFireNotice[] + created_at: string +} + +export interface Schedule { + id: string + name: string + workflow_id: string + workflow_name: string + /** 五段 cron:分 时 日 月 星期 */ + cron: string + /** IANA 时区名,如 Asia/Shanghai */ + timezone: string + run_class: ScheduleRunClass + /** 字段名 → 模板字符串,变量({{ schedule.yesterday }} 这类)按触发时间计算 */ + input_template: Record + enabled: boolean + /** 上一次还没结束时怎么办:目前只有跳过 */ + overlap: 'skip' + /** 错过触发时间后多少秒内恢复还补发一次 */ + misfire_grace_s: number + webhook: ScheduleWebhook | null + /** 暂停中、或者往后不会再触发时为 null */ + next_run_at: string | null + last_fire: ScheduleFire | null + /** 频率的中文说法,如「每周一 09:00(Asia/Shanghai)」 */ + description_text: string + created_by: string | null + created_at: string + updated_at: string + /** 乐观锁:修改时带上读到的版本,别人在这期间改过时回 409 */ + version: number +} + +/** 新建、修改时交上去的 Webhook。地址、密钥的明文只在写的时候给 */ +export interface ScheduleWebhookIn { + format: ScheduleWebhookFormat + /** 修改时:省略表示不改,空串表示删除整个 Webhook */ + url?: string + /** 修改时:省略表示不改,空串表示清除 */ + secret?: string + events: ScheduleEvent[] +} + +/** POST /api/schedules 的请求体 */ +export interface ScheduleCreate { + name: string + workflow_id: string + cron: string + timezone: string + run_class: ScheduleRunClass + input_template: Record + enabled: boolean + overlap: 'skip' + misfire_grace_s: number + webhook: ScheduleWebhookIn | null +} + +/** PATCH /api/schedules/{id} 的请求体:只带改了的字段,外加读到的版本 */ +export type SchedulePatch = Partial> & { + webhook?: ScheduleWebhookIn + version: number +} + +export interface SchedulePreviewRequest { + cron: string + timezone: string + input_template?: Record + workflow_id?: string + n?: number +} + +/** 预览:频率的中文说法、接下来几次触发时间、按下一次触发时间渲染出的输入。非法时 422 {detail, field} */ +export interface SchedulePreview { + description_text: string + next: string[] + inputs: Record +} + +/** 输入模板里能用的变量 */ +export interface ScheduleVariable { + /** 如 schedule.yesterday */ + name: string + /** 如「昨天(YYYY-MM-DD)」 */ + label: string + example: string +} + +/** POST /api/schedules/{id}/run:这次手动触发和它发起的运行(没发起时为 null) */ +export interface ScheduleRunResult { + fire: ScheduleFire + run_id: string | null +} + +/** GET /api/schedules/{id}/fires 的一页,新的在前;next_before 是下一页的游标,没有更多时为 null */ +export interface ScheduleFirePage { + items: ScheduleFire[] + next_before: string | null +} + +/** POST /api/schedules/{id}/webhook/test:用已保存的设置发一条测试消息 */ +export interface ScheduleWebhookTest { + ok: boolean + http_status: number | null + message: string +} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index b844cb6e..3139ce0a 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -96,7 +96,7 @@ const ESTIMATE = { stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 - schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33, + schedules: 60, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 02c86e1f..b125f897 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -1,8 +1,1429 @@ -// 定时任务的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。 -// 定时任务的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行 -// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。 +// 定时任务(/schedules、/schedules/new、/schedules/:id,记录页里运行名旁的「定时」标记)的回归检查。 +// +// 守的是这些「看着没坏、其实不能用」的地方:预设生成的 cron 不对(选了每周一 09:00,交上去的是每天)、时区按本机写 +// 而不是按定时任务的时区、预览不防抖、非法 cron 只弹一句不知道指哪一项、启用开关不带版本号(别人改过也照样覆盖)、 +// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、 +// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。 +// 还有几处时序和口径:晚到的刷新把刚切换的开关盖回去、先开触发历史再开设置时带着旧版本撞假的 409、保存途中又改了 +// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段) +// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。 +// +// 浏览器的本机时区故意设成 America/Los_Angeles,和定时任务的时区(Asia/Shanghai、America/New_York)都不同: +// 两者相同时「按定时任务时区写」和「按本机写」在页面上看不出区别,时间相关的断言一律比对按定时任务时区写出的原文。 +// +// 页面是真页面(/schedules 走真的应用外壳),接口全部在浏览器层答掉:定时任务、触发历史、预览、变量、工作流列表 +// 都是这里的假数据(虚构的门店周报、库存日报),写请求照约定改这里的假状态;设置接口读真的、只把 notify 一组换成 +// 假的,写请求不放行。别的 GET 放行到服务端(外壳要它),别的写请求一律拦成 503,不碰真实写接口。 +// 另有一节纯函数(pages/schedules/model.ts:预设 ⇄ cron、按时区写时间、表单 ⇄ 请求体),借 vite 转译后直接调用。 // // node scripts/check-schedules.mjs -console.log('=== 定时任务 ===') -console.log(' ✓ 占位:功能开发中') -console.log('\n✓ 定时任务:占位通过') +// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api … +// SCHEDULES_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核;CHECK_ONLY=列表,新建 只跑段名里含这些字的段。 +import { mkdirSync } from 'node:fs' +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api' +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +const SHOTS = process.env.SCHEDULES_SHOTS ?? '' +if (SHOTS) mkdirSync(SHOTS, { recursive: true }) + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean) +const opened = new Set() +async function section(name, fn) { + if (ONLY.length && !ONLY.some((k) => name.includes(k))) return + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } finally { + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +} + +// ---------------------------------------------------------------- 纯函数:借 vite 转译(同 check-trace) + +const loaded = new Map() +async function load(path) { + if (loaded.has(path)) return loaded.get(path) + const res = await fetch(`${WEB}${path}?t=${Date.now()}`) + if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`) + let code = await res.text() + const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1])) + for (const dep of deps) { + const url = await load(dep) + const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g') + code = code.replace(pattern, JSON.stringify(url)) + } + const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64') + loaded.set(path, url) + return url +} + +await section('纯函数:预设 ⇄ cron', async () => { + const m = await import(await load('/src/pages/schedules/model.ts')) + const f = (patch) => ({ ...m.DEFAULT_FREQ, ...patch }) + const cases = [ + ['每天 09:30', f({ preset: 'daily', time: '09:30' }), '30 9 * * *'], + ['每周一 09:00', f({ preset: 'weekly', time: '09:00', weekdays: [1] }), '0 9 * * 1'], + ['工作日连成区间', f({ preset: 'weekly', time: '09:00', weekdays: [5, 1, 3, 2, 4] }), '0 9 * * 1-5'], + ['周末逐个列出', f({ preset: 'weekly', time: '09:00', weekdays: [6, 0] }), '0 9 * * 0,6'], + ['不连着的逐个列出', f({ preset: 'weekly', time: '18:15', weekdays: [1, 2, 4] }), '15 18 * * 1,2,4'], + ['一周七天写 *', f({ preset: 'weekly', time: '09:00', weekdays: [0, 1, 2, 3, 4, 5, 6] }), '0 9 * * *'], + ['每月 15 日 08:05', f({ preset: 'monthly', time: '08:05', monthday: 15 }), '5 8 15 * *'], + ['每隔 6 小时在第 15 分', f({ preset: 'hourly', everyHours: 6, minute: 15 }), '15 */6 * * *'], + ['每隔 1 小时写 *', f({ preset: 'hourly', everyHours: 1, minute: 0 }), '0 * * * *'], + ['自定义去掉多余空白', f({ preset: 'custom', cron: ' 0 9 * * 1 ' }), '0 9 * * 1'], + ['钟点没填不猜', f({ preset: 'daily', time: '' }), ''], + ['每周一天都没选不猜', f({ preset: 'weekly', weekdays: [] }), ''], + ['每隔 5 小时不是 24 的约数', f({ preset: 'hourly', everyHours: 5, minute: 0 }), ''], + ] + const bad = cases.filter(([, freq, want]) => m.cronOf(freq) !== want) + check(`cronOf:${cases.length} 种写法`, !bad.length, bad.map(([n, freq, want]) => `${n}:${m.cronOf(freq)} ≠ ${want}`).join(';')) + + const back = [ + ['0 9 * * 1', { preset: 'weekly', time: '09:00', weekdays: [1] }], + ['0 9 * * 1-5', { preset: 'weekly', weekdays: [1, 2, 3, 4, 5] }], + ['0 9 * * 7', { preset: 'weekly', weekdays: [0] }], + ['30 7 * * *', { preset: 'daily', time: '07:30' }], + ['5 8 15 * *', { preset: 'monthly', time: '08:05', monthday: 15 }], + ['15 */6 * * *', { preset: 'hourly', everyHours: 6, minute: 15 }], + ['0 * * * *', { preset: 'hourly', everyHours: 1, minute: 0 }], + ['0 9 1,15 * *', { preset: 'custom' }], + ['0 9 * 1 *', { preset: 'custom' }], + ['*/5 * * * *', { preset: 'custom' }], + ['0 */5 * * *', { preset: 'custom' }], + ['0 9 * * MON', { preset: 'custom', cron: '0 9 * * MON' }], + ['0 9 * *', { preset: 'custom' }], + ] + const wrong = back.filter(([cron, want]) => { + const got = m.freqOf(cron) + return Object.entries(want).some(([k, v]) => JSON.stringify(got[k]) !== JSON.stringify(v)) + }) + check(`freqOf:${back.length} 条 cron 落回预设,认不出的算自定义、原文照放`, !wrong.length, + wrong.map(([cron]) => `${cron} → ${JSON.stringify(m.freqOf(cron))}`).join(';')) + const round = ['0 9 * * 1', '0 9 * * 1-5', '0 9 * * 0,6', '30 7 * * *', '5 8 15 * *', '15 */6 * * *', '0 9 1,15 * *'] + .filter((c) => m.cronOf(m.freqOf(c)) !== c) + check('认得出的写法来回一趟不变', !round.length, round.join(';')) +}) + +await section('纯函数:按定时任务的时区写时间', async () => { + const m = await import(await load('/src/pages/schedules/model.ts')) + const d = new Date('2026-10-05T01:00:00Z') + const now = new Date('2026-10-01T00:00:00Z') + check('Asia/Shanghai:2026-10-05 周一 09:00', m.formatZoned(d, 'Asia/Shanghai', 'full') === '2026-10-05 周一 09:00', + m.formatZoned(d, 'Asia/Shanghai', 'full')) + check('America/New_York:同一时刻是前一天 21:00', m.formatZoned(d, 'America/New_York', 'full') === '2026-10-04 周日 21:00', + m.formatZoned(d, 'America/New_York', 'full')) + check('同一年的短写法省掉年份', m.formatZoned(d, 'Asia/Shanghai', 'short', now) === '10/5 周一 09:00', + m.formatZoned(d, 'Asia/Shanghai', 'short', now)) + check('跨年的短写法带年份', m.formatZoned(new Date('2027-01-04T01:00:00Z'), 'Asia/Shanghai', 'short', now) === '2027/1/4 周一 09:00') + check('偏移:北京 UTC+08:00、纽约十月 UTC-04:00、一月 UTC-05:00、UTC', + m.offsetLabel('Asia/Shanghai', d) === 'UTC+08:00' && m.offsetLabel('America/New_York', d) === 'UTC-04:00' + && m.offsetLabel('America/New_York', new Date('2027-01-04T01:00:00Z')) === 'UTC-05:00' && m.offsetLabel('UTC', d) === 'UTC', + [m.offsetLabel('Asia/Shanghai', d), m.offsetLabel('America/New_York', d), m.offsetLabel('UTC', d)].join(' ')) + check('浏览器不认的时区名不抛错', m.zonedParts(d, 'Mars/Base') === null && typeof m.formatZoned(d, 'Mars/Base') === 'string') + const t = d.getTime() + const until = [ + [t - 3 * 3600e3 - 5 * 60e3, '3 小时后'], [t - 59.6 * 60e3, '1 小时后'], [t - 30e3, '不到 1 分钟后'], + [t - 12 * 60e3, '12 分钟后'], [t - 23.6 * 3600e3, '1 天后'], [t - 50 * 3600e3, '2 天后'], + ].filter(([nowMs, want]) => m.formatUntil(d, nowMs) !== want) + check('距下次运行:先取整再判档', !until.length, until.map(([n, w]) => `${m.formatUntil(d, n)} ≠ ${w}`).join(';')) + check('已经过了的不写「…后」', m.formatUntil(d, t + 1000) === null) + const opts = m.timezoneOptions('Europe/Berlin', 'Asia/Shanghai', d) + check('时区下拉:本机排第一、正在用的紧跟、不重复,写城市和偏移', + opts[0].tz === 'Asia/Shanghai' && opts[0].label.includes('本机') && opts[0].label.includes('UTC+08:00') + && opts[1].tz === 'Europe/Berlin' && new Set(opts.map((o) => o.tz)).size === opts.length, + opts.slice(0, 2).map((o) => o.label).join(' | ')) + const odd = m.timezoneOptions('Antarctica/Troll', 'Asia/Shanghai', d) + check('不在常用表里的时区也列上', odd.some((o) => o.tz === 'Antarctica/Troll')) +}) + +await section('纯函数:表单 ⇄ 请求体', async () => { + const m = await import(await load('/src/pages/schedules/model.ts')) + const saved = { + id: 's1', name: '门店周报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1', timezone: 'Asia/Shanghai', + run_class: 'formal', input_template: { date: '{{ schedule.yesterday }}' }, enabled: true, overlap: 'skip', misfire_grace_s: 3600, + webhook: { format: 'feishu', url_hint: 'open.feishu.cn/…ab12', has_secret: true, events: ['failed', 'finished'] }, + next_run_at: null, last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', created_by: null, + created_at: '2026-10-01T00:00:00Z', updated_at: '2026-10-01T00:00:00Z', version: 3, + } + const base = m.formOf(saved) + check('读出来的表单:每周一 09:00、Webhook 开着、地址不回填', base.freq.preset === 'weekly' && base.freq.time === '09:00' + && base.webhook.on && base.webhook.url === '' && !base.webhook.replaceUrl, JSON.stringify(base.freq)) + check('什么都没改:不发请求', m.patchBody(base, base, saved) === null) + const reorder = { ...base, webhook: { ...base.webhook, events: ['finished', 'failed'] } } + check('事件只是换了勾选顺序:不算改动', m.patchBody(reorder, base, saved) === null) + const renamed = m.patchBody({ ...base, name: ' 门店周报(新)' }, base, saved) + check('只改名字:只交名字,不带 Webhook(地址省略即不改)', JSON.stringify(renamed) === JSON.stringify({ name: '门店周报(新)' }), + JSON.stringify(renamed)) + const events = m.patchBody({ ...base, webhook: { ...base.webhook, events: ['finished'] } }, base, saved) + check('只改事件:Webhook 带格式和事件,不带地址和密钥', JSON.stringify(events) === JSON.stringify({ webhook: { format: 'feishu', events: ['finished'] } }), + JSON.stringify(events)) + const replaced = m.patchBody({ ...base, webhook: { ...base.webhook, replaceUrl: true, url: ' https://open.feishu.cn/hook/new9 ' } }, base, saved) + check('替换地址:带新地址', replaced?.webhook?.url === 'https://open.feishu.cn/hook/new9' && !('secret' in replaced.webhook), JSON.stringify(replaced)) + const removed = m.patchBody({ ...base, webhook: { ...base.webhook, on: false } }, base, saved) + check('删除 Webhook:地址交空串', removed?.webhook?.url === '' && removed.webhook.format === 'feishu', JSON.stringify(removed)) + const cleared = m.patchBody({ ...base, webhook: { ...base.webhook, secretMode: 'clear' } }, base, saved) + check('清除密钥:密钥交空串、地址省略', cleared?.webhook?.secret === '' && !('url' in cleared.webhook), JSON.stringify(cleared)) + const freq = m.patchBody({ ...base, freq: { ...base.freq, weekdays: [1, 3] } }, base, saved) + check('改频率:交新的 cron', freq?.cron === '0 9 * * 1,3' && Object.keys(freq).length === 1, JSON.stringify(freq)) + const odd = { ...saved, cron: '0 9 * * 0-6' } + const oddForm = m.formOf(odd) + check('cron 原文写法不同(0-6)但人没动它:不算改了频率', m.patchBody({ ...oddForm, name: 'x' }, oddForm, odd)?.cron === undefined) + const blanks = m.patchBody({ ...base, inputs: { date: '{{ schedule.yesterday }}', region: ' ' } }, base, saved) + check('只填了空白的输入不算填了', blanks === null, JSON.stringify(blanks)) + + const fresh = { ...m.formOf(null, 'Asia/Shanghai'), name: ' 库存日报 ', workflowId: 'wf-stock', + freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [1], time: '09:00' }, inputs: { date: '{{ schedule.yesterday }}', region: '' } } + const created = m.createBody({ ...fresh, webhook: { ...fresh.webhook, on: true, format: 'wecom', url: 'https://qyapi.weixin.qq.com/x?key=1', events: ['failed', 'finished'] } }) + check('新建的请求体:cron、时区、去掉没填的输入、没填密钥就不带', created.cron === '0 9 * * 1' && created.timezone === 'Asia/Shanghai' + && created.name === '库存日报' && JSON.stringify(created.input_template) === JSON.stringify({ date: '{{ schedule.yesterday }}' }) + && created.webhook && !('secret' in created.webhook) && JSON.stringify(created.webhook.events) === '["finished","failed"]' + && created.overlap === 'skip' && created.misfire_grace_s === 3600 && created.enabled === true, JSON.stringify(created)) + check('Webhook 关着:交 null', m.createBody(fresh).webhook === null) + + const probs = m.formProblems({ ...m.formOf(null, 'Asia/Shanghai'), webhook: { ...fresh.webhook, on: true, url: 'ftp://x', events: [] } }, null) + check('问题:缺名称、缺工作流、地址不是 http(s)、一个事件都没选', !!probs.name && !!probs.workflow && !!probs.url && !!probs.events + && !probs.freq, JSON.stringify(probs)) + const keep = m.formProblems({ ...base }, saved) + check('已保存过地址、没点替换:不要求重填地址', !keep.url, JSON.stringify(keep)) + check('频率不完整各有说法', m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] }) === '至少选择一天' + && m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'custom', cron: '0 9 * *' }) === 'cron 表达式应为五段,以空格分隔' + && m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'daily', time: '' }) === '请填写时间') +}) + +await section('纯函数:服务端会拒的组合、保存回来怎么接、测试结果', async () => { + const m = await import(await load('/src/pages/schedules/model.ts')) + const fields = [{ name: 'date', required: true }, { name: 'region', required: true, default: '华东' }, { name: 'note' }, + { name: 'blank_default', required: true, default: '' }] + const ip = (inputs, fs = fields) => JSON.stringify(m.inputProblems(inputs, fs)) + const cases = [ + ['都填了', { date: '{{ schedule.yesterday }}', blank_default: 'x' }, { unknown: [], missing: [] }], + ['必填没填(默认值为空串也算没有默认值)', {}, { unknown: [], missing: ['date', 'blank_default'] }], + ['只填了空白算没填', { date: ' ', blank_default: 'x' }, { unknown: [], missing: ['date'] }], + ['多出来的字段', { date: 'a', blank_default: 'x', draft_only: 'y' }, { unknown: ['draft_only'], missing: [] }], + ['多出来但留空的不交、不算', { date: 'a', blank_default: 'x', draft_only: '' }, { unknown: [], missing: [] }], + ] + const bad = cases.filter(([, inputs, want]) => ip(inputs) !== JSON.stringify(want)) + check(`inputProblems:${cases.length} 种情况`, !bad.length, bad.map(([n, inputs]) => `${n}:${ip(inputs)}`).join(';')) + check('入口没声明字段:什么键都收,不报', ip({ anything: 'x' }, []) === JSON.stringify({ unknown: [], missing: [] })) + + const fresh = { ...m.formOf(null, 'Asia/Shanghai'), name: '库存日报', workflowId: 'wf-stock', runClass: 'formal' } + const p1 = m.formProblems(fresh, null, { published: null, fields: [{ name: 'warehouse', required: true }] }) + check('新建:正式运行 + 没发布、必填没填,两样都拦', !!p1.runClass && !!p1.inputs, JSON.stringify(p1)) + check('……工作流还没取回来(发布状态不知道)时不判', !m.formProblems(fresh, null, {}).runClass) + check('……探索运行不要求发布', !m.formProblems({ ...fresh, runClass: 'exploratory' }, null, { published: null }).runClass) + const saved = { + id: 's1', name: '门店周报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1', timezone: 'Asia/Shanghai', + run_class: 'formal', input_template: {}, 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: 3, + } + const edit = m.formOf(saved) + const ctx = { published: null, fields: [{ name: 'date', required: true }] } + check('修改:没动工作流、运行类别、输入时服务端不重新核对,前端也不拦(工作流后来撤了发布也能改名、停用)', + Object.keys(m.formProblems({ ...edit, name: '新名字' }, saved, { ...ctx, recheck: false })).length === 0) + check('……动了这三样就照新建一样拦', !!m.formProblems(edit, saved, { ...ctx, recheck: true }).runClass) + + const sv = m.serverFieldValue + check('服务端指的那一项:改了它值就变,改别的不变', sv('name', { ...edit, name: 'X' }) !== sv('name', { ...edit, name: 'XY' }) + && sv('name', edit) === sv('name', { ...edit, misfireGraceS: 60 }) + && sv('run_class', edit) !== sv('run_class', { ...edit, runClass: 'exploratory' }) + && sv('misfire_grace_s', edit) !== sv('misfire_grace_s', { ...edit, misfireGraceS: 60 }) + && sv('not_a_field', edit) === null) + + const wbase = { ...edit, webhook: { ...edit.webhook, on: true, format: 'feishu' } } + const sent = { ...wbase, webhook: { ...wbase.webhook, replaceUrl: true, url: 'https://open.feishu.cn/hook/secret-token-zz99' } } + const back = { ...wbase, webhook: { ...wbase.webhook, replaceUrl: false, url: '' } } + check('保存途中没再动:换成刚存下的', JSON.stringify(m.rebaseForm(sent, sent, back)) === JSON.stringify(back)) + const during = m.rebaseForm({ ...sent, name: '途中改名' }, sent, back) + check('保存途中改了名字:名字留着,地址换成刚存下的(不再留明文)', during.name === '途中改名' && during.webhook.url === '' + && during.webhook.replaceUrl === false, JSON.stringify(during.webhook)) + const evs = m.rebaseForm({ ...sent, webhook: { ...sent.webhook, events: ['failed'] } }, sent, back) + check('……途中改了 Webhook 的事件:事件留着,地址照样换掉', JSON.stringify(evs.webhook.events) === '["failed"]' && evs.webhook.url === '') + + const t = m.webhookTestText + const tests = [ + [{ ok: true, http_status: 200, message: '请到接收端确认已收到' }, '测试消息已送达(HTTP 200):请到接收端确认已收到'], + [{ ok: true, http_status: 200, message: '测试消息已送达(HTTP 200)' }, '测试消息已送达(HTTP 200)'], + [{ ok: true, http_status: 204, message: '' }, '测试消息已送达(HTTP 204)'], + [{ ok: false, http_status: 404, message: 'open.feishu.cn 返回 HTTP 404:not found' }, '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found'], + [{ ok: false, http_status: 404, message: '推送失败:open.feishu.cn 返回 HTTP 404:not found' }, '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found'], + [{ ok: false, http_status: null, message: '无法连接 open.feishu.cn' }, '测试消息发送失败:无法连接 open.feishu.cn'], + [{ ok: false, http_status: 200, message: 'open.feishu.cn 返回错误码 19002:bad sign' }, '测试消息发送失败(HTTP 200):open.feishu.cn 返回错误码 19002:bad sign'], + ] + const wrong = tests.filter(([r, want]) => t(r) !== want) + check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';')) +}) + +// ---------------------------------------------------------------- 夹具:虚构的门店周报、库存日报 + +const NOW = Date.now() +const iso = (ms) => new Date(ms).toISOString() +const H = 3600e3 +/** 下一个北京时间周一 09:00(UTC 01:00)之后再往后 k 周(k 为负数是往前) */ +function nextMonday9(k = 0) { + const d = new Date(NOW) + d.setUTCHours(1, 0, 0, 0) + while (d.getUTCDay() !== 1 || d.getTime() <= NOW) d.setUTCDate(d.getUTCDate() + 1) + return d.getTime() + k * 7 * 24 * H +} + +// 按时区写时刻:和页面同一套写法,这里独立实现一遍,用来比对页面上的原文 +const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] +const wallFormats = new Map() +/** 某个时刻在某个时区里的年月日、时分、星期(0 是周日) */ +function wall(ms, tz) { + if (!wallFormats.has(tz)) { + wallFormats.set(tz, new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric', + hour: 'numeric', minute: 'numeric', weekday: 'short', hourCycle: 'h23' })) + } + const p = Object.fromEntries(wallFormats.get(tz).formatToParts(new Date(ms)).map((x) => [x.type, x.value])) + return { y: +p.year, mo: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute, + wd: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday) } +} +const pad2 = (n) => String(n).padStart(2, '0') +/** full:「2026-10-12 周一 09:00」;short:同一年省掉年份「10/12 周一 09:00」,跨年写「2027/1/4 周一 09:00」 */ +function zoned(ms, tz, mode = 'short') { + const w = wall(ms, tz) + const hm = `${pad2(w.h)}:${pad2(w.mi)}` + if (mode === 'full') return `${w.y}-${pad2(w.mo)}-${pad2(w.d)} ${WEEKDAY[w.wd]} ${hm}` + const md = `${w.mo}/${w.d}` + return `${w.y === wall(Date.now(), tz).y ? md : `${w.y}/${md}`} ${WEEKDAY[w.wd]} ${hm}` +} +/** + * 一个时区里从现在往后符合「分 时 * * 星期」的前 n 个时刻(按 15 分钟一格往后找,够用),照真服务端那样按所选时区算。 + * 认不出的写法返回 null + */ +function occurrences(cron, tz, n) { + const [mi, ho, dom, mon, dow] = String(cron).trim().split(/\s+/) + const minute = Number(mi) + const hour = Number(ho) + if (!/^\d+$/.test(mi ?? '') || !/^\d+$/.test(ho ?? '') || dom !== '*' || mon !== '*') return null + const days = dow === '*' ? null : new Set(String(dow).split(',').map((d) => Number(d) % 7)) + const out = [] + for (let t = Math.ceil((NOW + 1) / 900e3) * 900e3, i = 0; out.length < n && i < 8 * 7 * 96; t += 900e3, i++) { + const w = wall(t, tz) + if (w.h === hour && w.mi === minute && (!days || days.has(w.wd))) out.push(t) + } + return out +} + +const WORKFLOWS = [ + { + id: 'wf-weekly', name: '门店周报', description: '各门店上周销售', tags: [], version: 4, is_template: false, status: 'published', + published_version: 3, run_count: 12, created_at: iso(NOW - 40 * 24 * H), updated_at: iso(NOW - 2 * 24 * H), + graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: { + fields: [{ name: 'date', required: true }, { name: 'region', default: '华东' }, { name: 'draft_only' }] } } }], edges: [] }, + }, + { + id: 'wf-stock', name: '库存日报', description: '各仓库存', tags: [], version: 2, is_template: false, status: 'draft', + published_version: null, run_count: 3, created_at: iso(NOW - 10 * 24 * H), updated_at: iso(NOW - 24 * H), + graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: { + fields: [{ name: 'warehouse', required: true }] } } }], edges: [] }, + }, +] +/** 正式运行的字段取已发布的 v3(比画布少一个 draft_only) */ +const VERSION3 = { + id: 'v3', version: 3, note: '发布', workflow_id: 'wf-weekly', published: true, graph: WORKFLOWS[0].graph, + input_fields: [{ name: 'date', required: true, description: '统计日期' }, { name: 'region', default: '华东' }], +} +const VARIABLES = [ + { name: 'schedule.yesterday', label: '昨天(YYYY-MM-DD)', example: '2026-10-04' }, + { name: 'schedule.today', label: '今天(YYYY-MM-DD)', example: '2026-10-05' }, + { name: 'schedule.last_week_start', label: '上周一(YYYY-MM-DD)', example: '2026-09-28' }, +] +const FEISHU_URL = 'https://open.feishu.cn/open-apis/bot/v2/hook/0f3c9a7e-demo-ab12' +const urlHint = (url) => { + const u = new URL(url) + return `${u.host}/…${url.slice(-4)}` +} + +const fire = (id, patch = {}) => ({ + id, fire_at: iso(NOW - 24 * H), kind: 'scheduled', status: 'started', run_id: `run-${id}`, run_status: 'succeeded', + reason: null, notified: [], created_at: iso(NOW - 24 * H), ...patch, +}) +const S1 = { + id: 's-weekly', name: '门店周报 · 每周', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1', + timezone: 'Asia/Shanghai', run_class: 'formal', input_template: { date: '{{ schedule.yesterday }}' }, enabled: true, + overlap: 'skip', misfire_grace_s: 3600, + webhook: { format: 'feishu', url_hint: urlHint(FEISHU_URL), has_secret: true, events: ['finished', 'failed'] }, + next_run_at: iso(NOW + 3 * H + 5 * 60e3), + last_fire: fire('f-last', { fire_at: iso(NOW - 6 * 24 * H), run_id: 'run-sched-1', notified: [{ event: 'finished', status: 'ok', at: iso(NOW - 6 * 24 * H) }] }), + description_text: '每周一 09:00(Asia/Shanghai)', created_by: '检查脚本', created_at: iso(NOW - 30 * 24 * H), + updated_at: iso(NOW - 24 * H), version: 3, +} +const S2 = { + id: 's-stock', name: '库存日报', workflow_id: 'wf-stock', workflow_name: '库存日报', cron: '30 7 * * *', timezone: 'America/New_York', + run_class: 'exploratory', input_template: {}, enabled: false, overlap: 'skip', misfire_grace_s: 300, webhook: null, + next_run_at: null, + last_fire: fire('f-skip', { status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#a1b2c3)尚未结束' }), + description_text: '每天 07:30(America/New_York)', created_by: null, created_at: iso(NOW - 9 * 24 * H), updated_at: iso(NOW - 9 * 24 * H), + version: 1, +} + +/** + * 触发历史:25 条,新的在前,按 20 条一页给。计划时间都是北京时间周一 09:00(往前数第 i+1 周)。 + * 几种类型、结局、通知结果各放一条。错过、没有补发的,服务端一律记成 kind=misfire、status=skipped_misfire, + * 原因却有两种,各放一条、原因照服务端的原话写:f03 是停机一周多、在宽限内恢复时积压的那一次(最近一次就是补发的 + * f02),f05 是超出补发宽限的。f06、f07 没有记原因(约定里 reason 可以是 null),看兜底的说明 + */ +function makeFires() { + const out = [] + const local = (at) => { + const w = wall(Date.parse(at), 'Asia/Shanghai') + return `${w.y}-${pad2(w.mo)}-${pad2(w.d)} ${pad2(w.h)}:${pad2(w.mi)}` + } + const missed = { kind: 'misfire', status: 'skipped_misfire', run_id: null, run_status: null } + for (let i = 0; i < 25; i++) { + const at = iso(nextMonday9(-(i + 1))) + let f = fire(`f${String(i).padStart(2, '0')}`, { fire_at: at, created_at: at }) + if (i === 0) f = { ...f, kind: 'manual', notified: [{ event: 'finished', status: 'ok', at }, { event: 'failed', status: 'failed', at }] } + if (i === 1) f = { ...f, status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#9f8e7d)尚未结束' } + if (i === 2) f = { ...f, kind: 'misfire', run_status: 'failed' } + if (i === 3) f = { ...f, ...missed, reason: `服务未运行,错过了 ${local(at)} 的触发;积压的触发不补,只发起最近一次` } + if (i === 4) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: '工作流「门店周报」还没有发布:正式运行需要已发布的版本' } + if (i === 5) f = { ...f, ...missed, reason: `服务未运行,错过了 ${local(at)} 的触发,超出补发宽限(1 小时),没有发起` } + if (i === 6) f = { ...f, ...missed, reason: null } + if (i === 7) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: null } + out.push(f) + } + return out +} + +const RUNS = [ + { id: 'run-sched-1', workflow_id: 'wf-weekly', workflow_name: '门店周报', status: 'succeeded', input: { date: '2026-10-04' }, + output: { 周报: '上周销售额 1,234 万元' }, error: null, usage: { duration_ms: 4200 }, run_class: 'formal', version: 3, + schedule_id: 's-weekly', created_at: iso(NOW - 2 * H), started_at: iso(NOW - 2 * H), finished_at: iso(NOW - 2 * H + 4200) }, + { id: 'run-manual-1', workflow_id: 'wf-weekly', workflow_name: '门店周报', status: 'succeeded', input: { date: '2026-10-03' }, + output: { 周报: '手动补一份' }, error: null, usage: { duration_ms: 3900 }, run_class: 'exploratory', version: null, + schedule_id: null, created_at: iso(NOW - 3 * H), started_at: iso(NOW - 3 * H), finished_at: iso(NOW - 3 * H + 3900) }, +] + +function freshState(patch = {}) { + return { + schedules: [structuredClone(S1), structuredClone(S2)], + fires: makeFires(), + notify: { public_base_url: null }, + runs: null, + ...patch, + } +} + +/** + * 预览:照真服务端那样按所选时区排出接下来几次(认不出的写法按每天一次凑数),非法的回 422 和出错的那一项 + * (照约定的形状)。state.previewNoZone 时时间不带时区后缀(UTC),看页面是不是和别处一个口径 + */ +function previewReply(body, state) { + const [mi] = String(body?.cron ?? '').split(/\s+/) + if (Number(mi) > 59) return { status: 422, json: { detail: '第 1 段(分钟)应在 0–59 之间,现为 61', field: 'cron' } } + if (body?.timezone === 'Mars/Base') return { status: 422, json: { detail: '不认识的时区:Mars/Base', field: 'timezone' } } + const tpl = body?.input_template ?? {} + if (Object.values(tpl).some((v) => String(v).includes('schedule.nope'))) { + return { status: 422, json: { detail: '输入「date」里有不认识的变量:schedule.nope', field: 'input_template' } } + } + const tz = body?.timezone ?? 'UTC' + const n = body?.n ?? 5 + const times = occurrences(body?.cron, tz, n) ?? Array.from({ length: n }, (_, k) => NOW + (k + 1) * 24 * H) + const next = times.map((t) => (state?.previewNoZone ? iso(t).replace(/Z$/, '') : iso(t))) + const day = wall(times[0] - 24 * H, tz) + const yesterday = `${day.y}-${pad2(day.mo)}-${pad2(day.d)}` + const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v).replace(/\{\{\s*schedule\.yesterday\s*\}\}/g, yesterday)])) + const weekly = body?.cron === '0 9 * * 1' + return { json: { description_text: `${weekly ? '每周一 09:00' : '每天 09:00'}(${tz})`, next, inputs } } +} + +/** 测试消息的答复照后端的写法:message 是状态码之外的补充说明 */ +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']) { + if (k in body) out[k] = body[k] + } + if ('webhook' in body) { + const w = body.webhook + if (w.url === '') out.webhook = null + else { + out.webhook = { + format: w.format, events: w.events, + url_hint: w.url ? urlHint(w.url) : s.webhook?.url_hint, + has_secret: w.secret === '' ? false : w.secret ? true : !!s.webhook?.has_secret, + } + } + } + out.version = s.version + 1 + out.updated_at = iso(Date.now()) + if (!out.enabled) out.next_run_at = null + else if (!s.enabled) out.next_run_at = iso(NOW + 3 * H + 5 * 60e3) + return out +} + +let realSettings = null +async function settingsOf(state) { + realSettings ??= await fetch(`${API}/settings`).then((r) => r.json()).catch(() => ({})) + return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify } +} + +/** 一个请求怎么答。返回 {status, json};返回 null 表示放行(GET)或拦下(写请求) */ +async function reply(state, method, path, url, body) { + if (state.override) { + const out = await state.override(method, path, body, url) + if (out) return out + } + const m = path.match(/^\/schedules\/([^/]+)(\/.*)?$/) + if (method === 'GET' && path === '/schedules') return { json: { items: state.schedules } } + if (method === 'POST' && path === '/schedules/preview') return previewReply(body, state) + if (method === 'GET' && path === '/schedules/variables') return { json: VARIABLES } + if (method === 'POST' && path === '/schedules') { + const id = 's-new' + const s = { + ...body, id, workflow_name: WORKFLOWS.find((w) => w.id === body.workflow_id)?.name ?? '', version: 1, + webhook: body.webhook ? { format: body.webhook.format, url_hint: urlHint(body.webhook.url), has_secret: !!body.webhook.secret, events: body.webhook.events } : null, + next_run_at: body.enabled ? iso(nextMonday9()) : null, last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', + created_by: '检查脚本', created_at: iso(Date.now()), updated_at: iso(Date.now()), + } + state.schedules = [s, ...state.schedules] + return { status: 201, json: s } + } + if (m) { + const id = decodeURIComponent(m[1]) + const rest = m[2] ?? '' + const s = state.schedules.find((x) => x.id === id) + if (!s) return { status: 404, json: { detail: '定时任务不存在或已被删除' } } + if (method === 'GET' && !rest) return { json: s } + if (method === 'PATCH' && !rest) { + if (body?.version !== s.version) { + return { status: 409, json: { detail: `这个定时任务已被修改(当前是第 ${s.version} 版,提交的是第 ${body?.version} 版),请刷新后再保存` } } + } + const next = applyPatch(s, body) + state.schedules = state.schedules.map((x) => (x.id === id ? next : x)) + return { json: next } + } + if (method === 'DELETE' && !rest) { + state.schedules = state.schedules.filter((x) => x.id !== id) + return { status: 204 } + } + if (method === 'POST' && rest === '/run') { + const f = fire('f-manual', { fire_at: iso(Date.now()), kind: 'manual', run_id: 'run-new-1', run_status: 'queued' }) + state.schedules = state.schedules.map((x) => (x.id === id ? { ...x, last_fire: f } : x)) + return { json: { fire: f, run_id: 'run-new-1' } } + } + if (method === 'GET' && rest === '/fires') { + const limit = Number(url.searchParams.get('limit') ?? 20) + const before = url.searchParams.get('before') + const start = before ? state.fires.findIndex((f) => f.fire_at === before) + 1 : 0 + const items = state.fires.slice(start, start + limit) + const more = start + limit < state.fires.length + return { json: { items, next_before: more ? items.at(-1).fire_at : null } } + } + if (method === 'POST' && rest === '/webhook/test') return { json: state.webhookTest ?? WEBHOOK_TEST_OK } + } + if (method === 'GET' && path === '/workflows') return { json: WORKFLOWS } + if (method === 'GET' && path === '/workflows/wf-weekly/versions/3') return { json: VERSION3 } + if (method === 'GET' && path === '/settings') return { json: await settingsOf(state) } + if (method === 'PUT' && path === '/settings') { + if (body?.values?.notify) state.notify = body.values.notify + return { json: await settingsOf(state) } + } + if (state.runs && method === 'GET' && path === '/runs') return { json: state.runs } + if (state.runs && method === 'GET' && path.startsWith('/runs/')) { + const run = state.runs.find((r) => path === `/runs/${r.id}`) + if (run) return { json: run } + } + return null +} + +// ---------------------------------------------------------------- 页面 + +const browser = await chromium.launch({ executablePath: CHROME }) + +/** 浏览器的本机时区:和夹具里定时任务的时区都不同,见开头的说明 */ +const LOCAL_TZ = 'America/Los_Angeles' + +/** 开一页:接口按 reply 答,记下每个请求;所有原生对话框、页面报错都记下,最后核对 */ +async function open(path, { state = freshState(), width = 1280, height = 900, theme = 'dark' } = {}) { + state.theme = theme + const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: LOCAL_TZ }) + opened.add(ctx) + ctx.setDefaultTimeout(6000) + ctx.setDefaultNavigationTimeout(30000) + await ctx.addInitScript((t) => { + try { + localStorage.setItem('agentlab.theme', t) + localStorage.setItem('agentlab_actor', '检查脚本') + } catch { /* noop */ } + }, theme) + const page = await ctx.newPage() + const sent = [] + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) + await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { + const r = route.request() + const url = new URL(r.url()) + const method = r.method() + const p = decodeURIComponent(url.pathname.replace(/^\/api/, '')) + let body = null + try { body = r.postDataJSON() } catch { body = null } + sent.push({ key: `${method} ${p}`, body, search: url.search, at: Date.now() }) + const out = await reply(state, method, p, url, body) + if (out) { + return route.fulfill({ status: out.status ?? 200, contentType: 'application/json', + body: out.json === undefined ? '' : JSON.stringify(out.json) }) + } + if (method === 'GET') return route.continue() + return route.fulfill({ status: 503, body: '' }) + }) + await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' }) + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + return { ctx, page, sent, errors, state } +} + +const until = async (fn, ms = 6000, step = 100) => { + const end = Date.now() + ms + let v = await fn() + while (!v && Date.now() < end) { + await new Promise((r) => setTimeout(r, step)) + v = await fn() + } + return v +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) } +const writes = (sent, re) => sent.filter((s) => !s.key.startsWith('GET ') && re.test(s.key)) +/** 几段页面文字压成一行,失败时的说明不被换行截断 */ +const oneLine = (texts) => texts.map((t) => t.replace(/\s+/g, ' ').trim()).join(' | ') +const text = (page, sel) => page.locator(sel).first().innerText() +/** + * 本页自己的横向溢出:页面根和页里会横向滚动的容器(overflow-x 为 auto / scroll)。不量整页:390 宽时外壳里收起的 + * 导航提示本来就超出视口,与本页无关;也不量 overflow 为 hidden 的(截断加省略号是有意的) + */ +const overflow = (page) => page.evaluate(() => { + const root = document.querySelector('[data-schedules-page]') + if (!root) return 99 + const boxes = [root, ...[...root.querySelectorAll('*')].filter((el) => ['auto', 'scroll'].includes(getComputedStyle(el).overflowX))] + return Math.max(0, ...boxes.map((el) => el.scrollWidth - el.clientWidth)) +}) +/** 等表单保存完(按钮回到「保存」且不可点):只等请求发出去就往下走,回来的答复会落在下一步中间 */ +const saveDone = (page) => until(async () => { + const b = page.locator('[data-schedule-save]') + return (await b.isDisabled()) && !(await b.innerText()).includes('正在保存') +}) +/** 等预览算完、说的是这个频率 */ +const previewSays = (page, words) => until(async () => (await page.locator('[data-schedule-preview][data-status="ok"]').count()) === 1 + && (await page.locator('[data-schedule-preview]').innerText()).includes(words)) +/** 元素整个在视口的横向范围里 */ +const inViewX = (page, sel) => page.evaluate((s) => { + const el = document.querySelector(s) + if (!el) return false + const b = el.getBoundingClientRect() + return b.left >= 0 && b.right <= innerWidth + 0.5 +}, sel) + +// ---------------------------------------------------------------- 列表 + +await section('列表:渲染', async () => { + const { page, sent, errors } = await open('/schedules') + await page.locator('[data-schedule-row]').first().waitFor() + const rows = await page.locator('[data-schedule-row]').count() + check('两行定时任务', rows === 2, String(rows)) + const r1 = page.locator('[data-schedule-row="s-weekly"]') + const t1 = await r1.innerText() + check('名字、工作流、频率的中文说法', t1.includes('门店周报 · 每周') && t1.includes('门店周报') && t1.includes('每周一 09:00(Asia/Shanghai)'), + t1.replace(/\s+/g, ' ').slice(0, 120)) + check('名字链到详情', await r1.locator('[data-schedule-name]').getAttribute('href') === '/schedules/s-weekly') + const next = r1.locator('[data-schedule-next] time') + const nextText = await next.innerText() + const wantAbs = zoned(Date.parse(S1.next_run_at), 'Asia/Shanghai') + check(`下次运行:相对时间 + 按定时任务时区(Asia/Shanghai,不是本机的 ${LOCAL_TZ})的绝对时间`, + nextText.includes('3 小时后') && nextText.includes(wantAbs), `${nextText} | 应含 ${wantAbs}`) + const title = await next.getAttribute('title') + check('下次运行悬停看时区', !!title && title.includes('Asia/Shanghai'), title ?? '') + check('下次运行的 datetime 是 ISO', (await next.getAttribute('datetime')) === S1.next_run_at) + const last = r1.locator('[data-schedule-last] a') + check('上次触发链到那次运行', await last.getAttribute('href') === '/runs/run-sched-1', await last.getAttribute('href') ?? '无链接') + check('上次触发写运行的状态', (await last.innerText()).includes('已完成'), await last.innerText()) + const r2 = page.locator('[data-schedule-row="s-stock"]') + const t2 = await r2.innerText() + check('暂停的:标「已暂停」,下次运行写暂停中', t2.includes('已暂停') && t2.includes('暂停中'), t2.replace(/\s+/g, ' ').slice(0, 160)) + check('跳过的上次触发写明原因', t2.includes('上次未结束已跳过') + && ((await r2.locator('[data-schedule-last] [title]').first().getAttribute('title')) ?? '').includes('尚未结束')) + const lastAt = zoned(Date.parse(S2.last_fire.fire_at), 'America/New_York') + const lastText = await r2.locator('[data-schedule-last] time').innerText() + check('上次触发的时间按这一行自己的时区(America/New_York)写', lastText === lastAt, `${lastText} | 应为 ${lastAt}`) + check('启用开关是 switch,状态对得上', await r1.locator('[data-schedule-toggle]').getAttribute('role') === 'switch' + && await r1.locator('[data-schedule-toggle]').getAttribute('aria-checked') === 'true' + && await r2.locator('[data-schedule-toggle]').getAttribute('aria-checked') === 'false') + check('每行有立即运行、编辑、删除', await r1.locator('[data-schedule-run]').count() === 1 + && await r1.locator('[data-schedule-edit]').getAttribute('href') === '/schedules/s-weekly' + && await r1.locator('[data-schedule-delete]').count() === 1) + check('顶部有「新建定时任务」', (await page.locator('[data-schedule-create]').innerText()).includes('新建定时任务')) + const listWrites = writes(sent, /./) + check('只读:列表渲染不发写请求', listWrites.length === 0, listWrites.map((w) => w.key).join('、')) + await shot(page, 'list-dark') + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + +await section('列表:空态与老服务端', async () => { + const { page, errors } = await open('/schedules', { state: freshState({ schedules: [] }) }) + await page.locator('[data-schedules-empty]').waitFor() + const t = await text(page, '[data-schedules-empty]') + check('空态说明定时任务是什么、怎么建', t.includes('还没有定时任务') && t.includes('自动发起工作流运行') && t.includes('选择工作流'), t.replace(/\s+/g, ' ')) + check('空态里有新建按钮', await page.locator('[data-schedules-empty] [data-schedule-create]').count() === 1) + await page.locator('[data-schedules-empty] [data-schedule-create]').click() + check('点新建去 /schedules/new', await until(() => new URL(page.url()).pathname === '/schedules/new')) + + const old = freshState() + old.override = (method, path) => (path.startsWith('/schedules') ? { status: 404, json: { detail: 'Not Found' } } : null) + const o = await open('/schedules', { state: old }) + await o.page.locator('[data-schedules-unsupported]').waitFor() + check('老服务端(404):说服务端暂不支持,不露英文原文', (await text(o.page, '[data-schedules-unsupported]')).includes('服务端暂不支持定时任务') + && !(await o.page.locator('[data-schedules-page]').innerText()).includes('Not Found')) + check('没有页面报错', !errors.length && !o.errors.length, [...errors, ...o.errors].join(' | ')) +}) + +await section('列表:启用开关', async () => { + const { page, sent, errors, state } = await open('/schedules') + const toggle = page.locator('[data-schedule-row="s-weekly"] [data-schedule-toggle]') + await toggle.waitFor() + await toggle.click() + await until(async () => (await toggle.getAttribute('aria-checked')) === 'false') + const patches = writes(sent, /^PATCH \/schedules\/s-weekly$/) + check('点开关发一次 PATCH,带 enabled 和读到的版本', patches.length === 1 + && JSON.stringify(patches[0].body) === JSON.stringify({ enabled: false, version: 3 }), JSON.stringify(patches.map((p) => p.body))) + check('开关跟着变成关、行里标已暂停', (await page.locator('[data-schedule-row="s-weekly"]').innerText()).includes('已暂停')) + await toggle.click() + await until(async () => (await toggle.getAttribute('aria-checked')) === 'true') + const again = writes(sent, /^PATCH \/schedules\/s-weekly$/) + check('再点一次带上新版本(4)', again.length === 2 && again[1].body?.version === 4 && again[1].body?.enabled === true, + JSON.stringify(again.map((p) => p.body))) + // 别处改过:409 时说清是别处改的、已重新载入。服务端那句是写给表单保存的(「请刷新后再保存」),开关不照搬,放进详情 + state.schedules = state.schedules.map((s) => (s.id === 's-stock' ? { ...s, version: 7 } : s)) + const t2 = page.locator('[data-schedule-row="s-stock"] [data-schedule-toggle]') + await t2.click() + const conflictToast = page.locator('[data-toast]', { hasText: '别处被修改' }) + const toastText = await until(async () => (await conflictToast.count()) ? conflictToast.first().innerText() : null) + check('版本不符(409):说清是别处改的、已重新载入,不叫人去刷新、也不提保存', + !!toastText && toastText.includes('库存日报') && toastText.includes('已重新载入') && !toastText.includes('刷新') && !toastText.includes('保存'), + toastText ?? (await page.locator('[data-toast]').allInnerTexts()).join(' | ')) + check('……服务端的原话放在详情里', ((await conflictToast.first().locator('details').textContent().catch(() => '')) ?? '').includes('当前是第 7 版')) + const reloaded = await until(() => sent.filter((s) => s.key === 'GET /schedules').length >= 2) + check('……重新拉了一次列表,开关回到服务端的状态', !!reloaded && await t2.getAttribute('aria-checked') === 'false') + check('键盘:开关能聚焦、空格切换', await (async () => { + await toggle.focus() + await page.keyboard.press('Space') + return until(async () => (await toggle.getAttribute('aria-checked')) === 'false') + })()) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('列表:立即运行与删除', async () => { + const { page, sent, errors, state } = await open('/schedules') + const run = page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]') + await run.waitFor() + await run.click() + const ok = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('已发起运行')) ?? null) + check('立即运行发 POST', writes(sent, /^POST \/schedules\/s-weekly\/run$/).length === 1) + check('结果:已发起运行,可直达那次运行', !!ok && ok.includes('查看运行'), ok ?? '') + check('上次触发换成这一次(链到新运行)', await until(async () => + (await page.locator('[data-schedule-row="s-weekly"] [data-schedule-last] a').getAttribute('href')) === '/runs/run-new-1')) + await page.locator('[data-toast]', { hasText: '已发起运行' }).getByRole('button', { name: '查看运行' }).click() + check('点「查看运行」去 /runs/run-new-1', await until(() => new URL(page.url()).pathname === '/runs/run-new-1')) + + // 上一次还没结束:照样记一条手动触发,但跳过了 + state.override = (method, path) => (method === 'POST' && path === '/schedules/s-weekly/run' + ? { json: { fire: fire('f-skip2', { kind: 'manual', status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#run-new)尚未结束' }), run_id: null } } + : null) + await page.goto(`${WEB}/schedules`, { waitUntil: 'networkidle' }) + await page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]').click() + const skipped = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('尚未结束')) ?? null) + check('上一次没结束:说本次已跳过', !!skipped, skipped ?? '') + state.override = (method, path) => (method === 'POST' && path === '/schedules/s-weekly/run' + ? { json: { fire: fire('f-bad', { kind: 'manual', status: 'failed_to_start', run_id: null, run_status: null, reason: '正式运行需要已发布的版本' }), run_id: null } } + : null) + await page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]').click() + const refused = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('未能发起运行')) ?? null) + check('发起被拒:写出服务端给的原因', !!refused && refused.includes('正式运行需要已发布的版本'), refused ?? '') + state.override = null + + await page.locator('[data-schedule-row="s-stock"] [data-schedule-delete]').click() + const dialog = page.locator('[role="dialog"]') + await dialog.waitFor() + const dt = await dialog.innerText() + check('删除先确认(站内对话框),写清后果', dt.includes('删除定时任务「库存日报」') && dt.includes('已发起的运行'), dt.replace(/\s+/g, ' ')) + check('……确认前没发 DELETE', writes(sent, /^DELETE /).length === 0) + await dialog.getByRole('button', { name: '删除定时任务' }).click() + await until(async () => (await page.locator('[data-schedule-row="s-stock"]').count()) === 0) + check('确认后发 DELETE、行消失', writes(sent, /^DELETE \/schedules\/s-stock$/).length === 1 + && await page.locator('[data-schedule-row="s-stock"]').count() === 0) + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + +/** 某个 GET 的答复按请求到达那一刻的快照、晚 ms 才回:模拟一次在写操作之前读到旧值、之后才回来的刷新 */ +const slowGet = (state, path, pick, ms = 1500) => async (method, p) => { + if (method !== 'GET' || p !== path) return null + const snap = structuredClone(pick(state)) + await sleep(ms) + return { json: snap } +} + +await section('列表:晚到的刷新不盖掉刚切换的开关', async () => { + const state = freshState() + const { page, sent, errors } = await open('/schedules', { state }) + const row = page.locator('[data-schedule-row="s-weekly"]') + const toggle = row.locator('[data-schedule-toggle]') + await toggle.waitFor() + state.override = slowGet(state, '/schedules', (st) => ({ items: st.schedules })) + const lists = () => sent.filter((s) => s.key === 'GET /schedules').length + const before = lists() + // 立即运行之后静默刷新一次:这次读到的是开关之前的快照,晚 1.5 秒才回 + await row.locator('[data-schedule-run]').click() + await until(() => lists() > before) + await until(async () => !(await row.locator('[data-schedule-run]').isDisabled())) + await toggle.click() + check('开关先变成关', await until(async () => (await toggle.getAttribute('aria-checked')) === 'false')) + await sleep(1900) + check('晚到的列表答复不把开关盖回去', await toggle.getAttribute('aria-checked') === 'false' && await row.getAttribute('data-enabled') === 'false', + `aria-checked=${await toggle.getAttribute('aria-checked')}`) + state.override = null + await toggle.click() + await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 2) + const p = writes(sent, /^PATCH \/schedules\/s-weekly$/).map((x) => x.body) + check('再点一次带开关之后的版本(4),不撞 409', p[1]?.version === 4 && p[1]?.enabled === true, JSON.stringify(p)) + await sleep(300) + check('……没有冲突提示', !(await page.locator('[data-toast]').allInnerTexts()).some((t) => t.includes('别处被修改'))) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +// ---------------------------------------------------------------- 新建 + +/** 在新建页上填好「门店周报,每周一 09:00,飞书 Webhook」 */ +async function fillWeekly(page) { + const picker = page.locator('[data-workflow-picker] input') + await picker.click() + await picker.fill('周报') + await page.locator('[data-workflow-option="wf-weekly"]').waitFor() + check('工作流可搜索:输入「周报」只剩门店周报', await page.locator('[data-workflow-option]').count() === 1) + await page.locator('[data-workflow-option="wf-weekly"]').click() + check('选了工作流、名字空着时用工作流名填上', await page.locator('[data-field="name"]').inputValue() === '门店周报') + await page.locator('[data-field="name"]').fill('门店周报 · 每周') + await page.locator('[data-freq-preset="weekly"]').click() + check('选「每周」默认勾周一', await page.locator('[data-weekday="1"]').getAttribute('aria-pressed') === 'true' + && await page.locator('[data-weekday="3"]').getAttribute('aria-pressed') === 'false') + await page.locator('[data-freq-time]').fill('09:00') +} + +await section('新建:每周一 09:00 的请求体', async () => { + const { page, sent, errors } = await open('/schedules/new') + await page.locator('[data-schedule-form]').waitFor() + check('页头写「新建定时任务」', (await page.locator('[data-schedules-page] header h1').innerText()).includes('新建定时任务')) + check(`时区默认取本机(${LOCAL_TZ})`, await page.locator('[data-timezone]').inputValue() === LOCAL_TZ, + await page.locator('[data-timezone]').inputValue()) + await fillWeekly(page) + await page.locator('[data-timezone]').selectOption('Asia/Shanghai') + // 正式运行:字段取已发布的 v3(没有画布上的 draft_only) + await page.locator('[data-input-field="date"]').waitFor() + check('正式运行的输入字段取已发布的 v3', await page.locator('[data-input-field]').count() === 2 + && await page.locator('[data-input-field="draft_only"]').count() === 0 + && (await text(page, '[data-inputs-source]')).includes('v3')) + check('必填字段标出来(和发起运行的表单一样是红色星号,读屏念「必填」)', + await page.locator('[data-input-field="date"] [data-required][aria-label="必填"]').count() === 1 + && await page.locator('[data-input-field="region"] [data-required]').count() === 0) + await page.locator('[data-input-vars="date"]').click() + await page.locator('[data-input-field="date"] [data-var="schedule.yesterday"]').click() + check('点变量插入模板', await page.locator('[data-input-field="date"] input').inputValue() === '{{ schedule.yesterday }}', + await page.locator('[data-input-field="date"] input').inputValue()) + await previewSays(page, '每周一 09:00(Asia/Shanghai)') + const times = await page.locator('[data-preview-time]').allInnerTexts() + const want = occurrences('0 9 * * 1', 'Asia/Shanghai', 5).map((t) => zoned(t, 'Asia/Shanghai', 'full')) + check(`预览接下来 5 次触发:按所选时区(不是本机的 ${LOCAL_TZ})写,逐条对得上`, + times.length === 5 && times.every((t, i) => t.includes(want[i])) && want.every((w) => w.endsWith('周一 09:00')), + `${oneLine(times)} | 应为 ${want.join(' | ')}`) + check('预览写频率的中文说法', (await text(page, '[data-schedule-preview]')).includes('每周一 09:00')) + const rendered = await until(async () => { + const t = await page.locator('[data-input-rendered="date"]').innerText().catch(() => '') + return /\d{4}-\d{2}-\d{2}/.test(t) ? t : null + }) + check('输入旁边写渲染结果', !!rendered && rendered.includes('下次触发时为'), rendered ?? '') + // 预览防抖:连续改三次只发一两次 + const before = writes(sent, /^POST \/schedules\/preview$/).length + for (const t of ['09:01', '09:02', '09:00']) await page.locator('[data-freq-time]').fill(t) + await sleep(1200) + const burst = writes(sent, /^POST \/schedules\/preview$/).length - before + check('预览防抖:连着改三次不发三次', burst >= 1 && burst <= 2, `${burst} 次`) + + // Webhook:飞书、地址、密钥,取消「等待审批」 + await page.locator('[data-webhook-on]').check() + await page.locator('[data-webhook-format="feishu"]').click() + await page.locator('[data-webhook-url]').fill(FEISHU_URL) + await page.locator('[data-webhook-secret]').fill('s3cr3t-demo') + await page.locator('[data-webhook-event="awaiting_approval"]').uncheck() + check('测试消息要先保存', await page.locator('[data-webhook-test]').isDisabled()) + 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 请求体:cron 0 9 * * 1', body?.cron === '0 9 * * 1', body?.cron) + check('POST 请求体:时区 Asia/Shanghai', body?.timezone === 'Asia/Shanghai', body?.timezone) + check('POST 请求体:工作流、名称、运行类别', body?.workflow_id === 'wf-weekly' && body?.name === '门店周报 · 每周' && body?.run_class === 'formal') + check('POST 请求体:输入模板只带填了的字段', JSON.stringify(body?.input_template) === JSON.stringify({ date: '{{ schedule.yesterday }}' }), + JSON.stringify(body?.input_template)) + check('POST 请求体:Webhook 格式、地址、密钥、事件', JSON.stringify(body?.webhook) === JSON.stringify({ + format: 'feishu', url: FEISHU_URL, secret: 's3cr3t-demo', events: ['finished', 'failed'] }), JSON.stringify(body?.webhook)) + check('POST 请求体:启用、重叠跳过、补发宽限', body?.enabled === true && body?.overlap === 'skip' && body?.misfire_grace_s === 3600, + JSON.stringify({ enabled: body?.enabled, overlap: body?.overlap, misfire_grace_s: body?.misfire_grace_s })) + check('创建后进详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new')) + await page.locator('[data-webhook-url-hint]').waitFor() + check('保存后地址只显示 url_hint', (await text(page, '[data-webhook-url-hint]')).includes('open.feishu.cn/…ab12')) + check('……页面上和输入框里都没有完整地址', !(await page.locator('[data-schedules-page]').innerText()).includes('0f3c9a7e') + && await page.locator('[data-webhook-url]').count() === 0 + && !(await page.locator('[data-schedules-page] input').evaluateAll((els) => els.map((e) => e.value))).some((v) => v.includes('0f3c9a7e'))) + check('……密钥显示「已设置」,不回填', (await text(page, '[data-webhook-secret-set]')).includes('已设置') + && await page.locator('[data-webhook-secret]').count() === 0) + await shot(page, 'detail-after-create') + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + +await section('新建:预览出错与保存前的问题', async () => { + const { page, sent, errors } = await open('/schedules/new') + await page.locator('[data-schedule-form]').waitFor() + check('没填完不能保存,按钮上说还缺什么', await page.locator('[data-schedule-save]').isDisabled() + && ((await page.locator('[data-schedule-save]').getAttribute('title')) ?? '').includes('名称')) + await fillWeekly(page) + await page.locator('[data-freq-preset="custom"]').click() + check('切到自定义:带上当前预设生成的 cron', await page.locator('[data-freq-cron]').inputValue() === '0 9 * * 1', + await page.locator('[data-freq-cron]').inputValue()) + await page.locator('[data-freq-cron]').fill('61 9 * * 1') + const err = await until(async () => { + const t = await page.locator('[data-freq-error]').innerText().catch(() => '') + return t.includes('0–59') ? t : null + }) + check('非法 cron:频率下面写服务端的说明', !!err, err ?? '') + check('……频率输入框标成出错,说明接到 aria-describedby', await page.locator('[data-freq-cron]').getAttribute('aria-invalid') === 'true' + && !!(await page.locator('[data-freq-cron]').getAttribute('aria-describedby'))) + check('……预览区不再摆上一次的时间', await page.locator('[data-preview-time]').count() === 0) + check('……不能保存', await page.locator('[data-schedule-save]').isDisabled()) + await page.locator('[data-freq-cron]').fill('0 9 * * 1') + await page.locator('[data-preview-time]').nth(4).waitFor() + check('改对之后说明消失、预览回来', await page.locator('[data-freq-error]').count() === 0) + await page.locator('[data-timezone]').selectOption('America/New_York') + await previewSays(page, '(America/New_York)') + const ny = occurrences('0 9 * * 1', 'America/New_York', 5).map((t) => zoned(t, 'America/New_York', 'full')) + const nyTimes = await page.locator('[data-preview-time]').allInnerTexts() + check('换时区之后预览按新时区写:5 个时刻逐条对得上', nyTimes.length === 5 && nyTimes.every((t, i) => t.includes(ny[i])), + `${oneLine(nyTimes)} | 应为 ${ny.join(' | ')}`) + await page.locator('[data-input-field="date"] input').fill('{{ schedule.nope }}') + const inErr = await until(async () => { + const t = await page.locator('[data-inputs-error]').innerText().catch(() => '') + return t.includes('schedule.nope') ? t : null + }) + check('模板里写错变量:输入下面写服务端的说明', !!inErr, inErr ?? '') + check('没有提交过 POST', writes(sent, /^POST \/schedules$/).length === 0) + // 键盘:频率预设是单选组,方向键切换 + await page.locator('[data-freq-preset="custom"]').focus() + await page.keyboard.press('ArrowLeft') + check('键盘:频率预设方向键切换', await page.locator('[data-freq-preset="hourly"]').getAttribute('aria-checked') === 'true') + check('表单字段都有 label', await page.locator('[data-schedule-form] input:not([type=hidden]), [data-schedule-form] select, [data-schedule-form] textarea') + .evaluateAll((els) => els.every((el) => el.labels?.length || el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')))) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('新建:离开前确认', async () => { + const { page, errors } = await open('/schedules/new') + await page.locator('[data-schedule-form]').waitFor() + await page.locator('[data-field="name"]').fill('半截') + await page.locator('nav[aria-label="主导航"] a[href="/runs"]').click() + const dialog = page.locator('[role="dialog"]') + check('改了一半离开:先问一句', await dialog.waitFor({ timeout: 3000 }).then(() => true, () => false) + && (await dialog.innerText()).includes('未保存的修改')) + await dialog.getByRole('button', { name: '取消' }).click() + check('……选留下:还在新建页', new URL(page.url()).pathname === '/schedules/new') + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('新建:换工作流、未发布与输入字段', async () => { + const { page, errors, state } = await open('/schedules/new') + await page.locator('[data-schedule-form]').waitFor() + const picker = page.locator('[data-workflow-picker] input') + const pick = async (id) => { + await picker.click() + await page.locator(`[data-workflow-option="${id}"]`).click() + } + const name = page.locator('[data-field="name"]') + const save = page.locator('[data-schedule-save]') + const saveTitle = async () => (await save.getAttribute('title')) ?? '' + await pick('wf-weekly') + check('选了门店周报:名称自动填成门店周报', await name.inputValue() === '门店周报') + await pick('wf-stock') + check('改选库存日报:自动填的名称跟着换', await name.inputValue() === '库存日报', await name.inputValue()) + check('库存日报没有发布:运行类别替人选探索运行', await page.locator('[data-run-class="exploratory"]').getAttribute('aria-checked') === 'true') + + // 服务端对「正式运行 + 没发布」在保存时就回 422:前端当场拦下,不说「到点才失败」 + await page.locator('[data-run-class="formal"]').click() + const problem = page.locator('[data-run-class-problem]') + const pt = await problem.innerText().catch(() => '') + check('没发布又选正式运行:运行类别下面写明要先发布或改用探索运行', pt.includes('发布') && pt.includes('探索运行') && !pt.includes('发起失败'), pt) + check('……不能保存,按钮上说还要处理运行类别', await save.isDisabled() && (await saveTitle()).includes('运行类别'), await saveTitle()) + await shot(page, 'new-not-published') + await page.locator('[data-run-class="exploratory"]').click() + check('改回探索运行:问题收起', await problem.count() === 0) + const wh = page.locator('[data-input-field="warehouse"]') + const wt = await wh.innerText() + check('必填且没有默认值:说要填写后才能保存,不说到点才失败', wt.includes('请填写') && !wt.includes('到点'), wt.replace(/\s+/g, ' ')) + check('……不能保存,按钮上说还要处理输入', await save.isDisabled() && (await saveTitle()).includes('输入'), await saveTitle()) + await wh.locator('input').fill('北仓') + check('填了之后可以保存', await until(async () => !(await save.isDisabled())), await saveTitle()) + await name.fill('我的日报') + await pick('wf-weekly') + check('人改过的名称换工作流时不覆盖', await name.inputValue() === '我的日报', await name.inputValue()) + + // 探索运行用画布上的字段(有 draft_only),正式运行用已发布的 v3(没有):交上去会被拒,写清是哪一版里没有 + await page.locator('[data-input-field="draft_only"] input').fill('只在画布上') + await page.locator('[data-input-field="date"] input').fill('2026-10-01') + await page.locator('[data-run-class="formal"]').click() + const stale = page.locator('[data-input-stale="draft_only"]') + await stale.waitFor() + const st = await stale.innerText() + check('切到正式运行:画布独有的字段写明已发布的 v3 中没有,不说工作流里没有', st.includes('已发布的 v3 中没有这个字段') && !st.includes('工作流中已没有'), + st.replace(/\s+/g, ' ')) + check('……交上去会被拒:不能保存,按钮上说还要处理输入', await save.isDisabled() && (await saveTitle()).includes('输入'), await saveTitle()) + if (SHOTS) { + // 等运行类别的颜色过渡播完、把这一行挪到保存栏上面再截 + await sleep(300) + await stale.evaluate((el) => el.scrollIntoView({ block: 'center' })) + } + await shot(page, 'new-stale-field') + await stale.getByRole('button', { name: '移除' }).click() + check('移除之后可以保存', await until(async () => !(await save.isDisabled())), await saveTitle()) + + // 保存被拒、指明运行类别:说明写在运行类别下面,不只在底部 + state.override = (method, path) => (method === 'POST' && path === '/schedules' + ? { status: 422, json: { detail: '「门店周报」还没有发布版本,正式运行只能从发布版本发起。请先在编排页发布,或改为探索运行', field: 'run_class' } } + : null) + await save.click() + const rcErr = await until(async () => (await page.locator('[data-run-class-error]').innerText().catch(() => '')) || null) + check('保存被拒、指明运行类别:说明写在运行类别下面', !!rcErr && rcErr.includes('还没有发布版本'), rcErr ?? '') + await shot(page, 'new-run-class-error') + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('新建:预览时间不带时区后缀', async () => { + const { page, errors } = await open('/schedules/new', { state: freshState({ previewNoZone: true }) }) + await page.locator('[data-schedule-form]').waitFor() + await fillWeekly(page) + await page.locator('[data-timezone]').selectOption('Asia/Shanghai') + await previewSays(page, '每周一 09:00(Asia/Shanghai)') + const times = await page.locator('[data-preview-time]').allInnerTexts() + const want = occurrences('0 9 * * 1', 'Asia/Shanghai', 5).map((t) => zoned(t, 'Asia/Shanghai', 'full')) + check('不带时区后缀的时间按 UTC 读(和列表、触发历史一个口径),不按本机时间读', times.length === 5 && times.every((t, i) => t.includes(want[i])), + `${oneLine(times)} | 应为 ${want.join(' | ')}`) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +// ---------------------------------------------------------------- 详情 + +await section('详情:Webhook 与运行链接地址', async () => { + const { page, sent, errors, state } = await open('/schedules/s-weekly') + await page.locator('[data-schedule-form]').waitFor() + check('页头写名字和频率', (await page.locator('[data-schedules-page] header').innerText()).includes('门店周报 · 每周')) + check('读出来的频率选中每周、勾周一', await page.locator('[data-freq-preset="weekly"]').getAttribute('aria-checked') === 'true' + && await page.locator('[data-weekday="1"]').getAttribute('aria-pressed') === 'true') + check('已保存的地址只显示 url_hint', (await text(page, '[data-webhook-url-hint]')).includes('open.feishu.cn/…ab12') + && await page.locator('[data-webhook-url]').count() === 0) + check('没改动时保存按钮不可点', await page.locator('[data-schedule-save]').isDisabled()) + // 测试结果:服务端的 message 是补充说明时接在结局后面;是整句(老写法)时不再套一层。状态码只写一次 + const testResult = async (reply, want) => { + state.webhookTest = reply + const before = writes(sent, /^POST \/schedules\/s-weekly\/webhook\/test$/).length + await page.locator('[data-webhook-test]').click() + await until(() => writes(sent, /^POST \/schedules\/s-weekly\/webhook\/test$/).length > before) + const got = await until(async () => { + const t = (await page.locator('[data-webhook-test-result]').innerText().catch(() => '')).trim() + return t === want ? t : null + }, 3000) + return got ?? (await page.locator('[data-webhook-test-result]').innerText().catch(() => '')).trim() + } + const times = (str, word) => str.split(word).length - 1 + const okText = await testResult(null, '测试消息已送达(HTTP 200):请到接收端确认已收到') + check('发送测试消息并显示结果:结局、状态码、补充说明各一遍', okText === '测试消息已送达(HTTP 200):请到接收端确认已收到' + && await page.locator('[data-webhook-test-result="ok"]').count() === 1, okText) + const whole = await testResult({ ok: true, http_status: 200, message: '测试消息已送达(HTTP 200)' }, '测试消息已送达(HTTP 200)') + check('……服务端给的是整句:不再套一层', whole === '测试消息已送达(HTTP 200)' && times(whole, 'HTTP 200') === 1, whole) + const fail = await testResult({ ok: false, http_status: 404, message: 'open.feishu.cn 返回 HTTP 404:not found' }, + '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found') + check('……发送失败:状态码只写一遍', fail === '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found' && times(fail, 'HTTP 404') === 1 + && await page.locator('[data-webhook-test-result="failed"]').count() === 1, fail) + const failOld = await testResult({ ok: false, http_status: 404, message: '推送失败:open.feishu.cn 返回 HTTP 404:not found' }, + '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found') + check('……「发送失败」「推送失败」不叠在一起', !failOld.includes('推送失败') && times(failOld, 'HTTP 404') === 1, failOld) + await page.locator('[data-webhook-test-result]').scrollIntoViewIfNeeded() + await shot(page, 'detail-webhook-test-failed') + state.webhookTest = null + // 只改事件:PATCH 不带地址 + await page.locator('[data-webhook-event="awaiting_approval"]').check() + check('改了 Webhook 设置后,测试要先保存', await page.locator('[data-webhook-test]').isDisabled()) + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 1) + await saveDone(page) + const p1 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[0]?.body + check('只改事件:PATCH 带版本、格式和事件,不带地址和密钥', JSON.stringify(p1) === JSON.stringify({ + webhook: { format: 'feishu', events: ['finished', 'failed', 'awaiting_approval'] }, version: 3 }), JSON.stringify(p1)) + check('保存后按钮回到不可点、地址提示还在', await page.locator('[data-schedule-save]').isDisabled() + && (await text(page, '[data-webhook-url-hint]')).includes('…ab12')) + // 替换地址 + await page.locator('[data-webhook-replace]').click() + await page.locator('[data-webhook-url]').fill('https://open.feishu.cn/open-apis/bot/v2/hook/new-demo-cd34') + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 2) + await saveDone(page) + const p2 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[1]?.body + check('替换地址:带新地址和新版本', p2?.webhook?.url === 'https://open.feishu.cn/open-apis/bot/v2/hook/new-demo-cd34' && p2?.version === 4 + && !('secret' in (p2?.webhook ?? {})), JSON.stringify(p2)) + check('……保存后又只显示 url_hint', await until(async () => (await text(page, '[data-webhook-url-hint]').catch(() => '')).includes('…cd34')) + && await page.locator('[data-webhook-url]').count() === 0) + // 运行链接地址:读写设置的 notify 组 + check('写出运行链接地址还没设置', (await text(page, '[data-link-base]')).includes('未设置')) + await page.locator('[data-link-base-edit]').click() + await page.locator('[data-link-base-input]').fill('ftp://bad') + check('地址不对时不让存', await page.locator('[data-link-base-save]').isDisabled()) + await page.locator('[data-link-base-input]').fill('https://agentlab.example.com/') + await page.locator('[data-link-base-save]').click() + await until(() => writes(sent, /^PUT \/settings$/).length === 1) + const put = writes(sent, /^PUT \/settings$/)[0]?.body + check('PUT /api/settings 只写 notify 一组(去掉末尾的斜杠)', JSON.stringify(put) === JSON.stringify({ + values: { notify: { public_base_url: 'https://agentlab.example.com' } } }), JSON.stringify(put)) + check('……页面上写出当前值', await until(async () => (await text(page, '[data-link-base]')).includes('https://agentlab.example.com'))) + check('假状态里存上了', state.notify.public_base_url === 'https://agentlab.example.com') + + // 删除 Webhook + await page.locator('[data-webhook-remove]').click() + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 3) + await saveDone(page) + const p3 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[2]?.body + check('删除 Webhook:地址交空串', p3?.webhook?.url === '' && p3?.version === 5, JSON.stringify(p3)) + check('……之后 Webhook 是关着的', await until(async () => !(await page.locator('[data-webhook-on]').isChecked()))) + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + +await section('详情:保存冲突与页头操作', async () => { + const { page, sent, errors, state } = await open('/schedules/s-weekly') + await page.locator('[data-schedule-form]').waitFor() + // 页头的开关:带版本,表单之后的保存用新版本 + await page.locator('header [data-schedule-toggle]').click() + await until(async () => (await page.locator('header [data-schedule-toggle]').getAttribute('aria-checked')) === 'false') + check('页头开关发 PATCH 带版本', JSON.stringify(writes(sent, /^PATCH /)[0]?.body) === JSON.stringify({ enabled: false, version: 3 })) + await page.locator('[data-field="name"]').fill('门店周报 · 每周(新)') + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH /).length === 2) + await saveDone(page) + check('之后保存表单用开关之后的版本(4),不把启用状态改回去', JSON.stringify(writes(sent, /^PATCH /)[1]?.body) === JSON.stringify({ + name: '门店周报 · 每周(新)', version: 4 }), JSON.stringify(writes(sent, /^PATCH /)[1]?.body)) + // 别处改过 + state.schedules = state.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s)) + await page.locator('[data-field="name"]').fill('又改一次') + await page.locator('[data-schedule-save]').click() + const conflict = await until(async () => (await page.locator('[data-schedule-conflict]').innerText().catch(() => '')) || null) + check('版本不符(409):明确说已在别处修改,并给重新载入', !!conflict && conflict.includes('别处被修改') && conflict.includes('当前是第 9 版') + && await page.locator('[data-schedule-reload]').count() === 1, conflict ?? '') + check('……没改页面上填的内容', await page.locator('[data-field="name"]').inputValue() === '又改一次') + await page.locator('[data-schedule-reload]').click() + const dialog = page.locator('[role="dialog"]') + await dialog.waitFor() + await dialog.getByRole('button', { name: '放弃修改并重新载入' }).click() + check('重新载入后换成服务端的设置', await until(async () => (await page.locator('[data-field="name"]').inputValue()) === '别处改过的名字')) + check('……冲突提示收起', await page.locator('[data-schedule-conflict]').count() === 0) + // 立即运行:结果、触发历史跟着刷新 + await page.locator('header [data-schedule-run]').click() + check('页头立即运行发 POST 并说结果', await until(async () => (await page.locator('[data-toast]').allInnerTexts()).some((x) => x.includes('已发起运行')))) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('详情:保存被拒后的字段说明', async () => { + const { page, errors, state } = await open('/schedules/s-weekly') + await page.locator('[data-schedule-form]').waitFor() + const reject = (field, detail, when = () => true) => { + state.override = (method, path, body) => (method === 'PATCH' && path === '/schedules/s-weekly' && when(body) + ? { status: 422, json: { detail, field } } : null) + } + const name = page.locator('[data-field="name"]') + const save = page.locator('[data-schedule-save]') + reject('name', '名称不能是单个字母', (b) => b?.name === 'X') + await name.fill('X') + await save.click() + const said = await until(async () => (await page.locator('#schedule-name-error').innerText().catch(() => '')) || null) + check('保存被拒、指明名称:名称下面写服务端的说明', !!said && said.includes('名称不能是单个字母'), said ?? '') + check('……名称框标成出错、说明接到 aria-describedby', await name.getAttribute('aria-invalid') === 'true' + && await name.getAttribute('aria-describedby') === 'schedule-name-error', + `${await name.getAttribute('aria-invalid')} | ${await name.getAttribute('aria-describedby')}`) + check('……底部写保存失败', await page.locator('[data-schedule-save-error]').count() === 1) + await shot(page, 'detail-name-rejected') + await name.fill('XY') + check('改了名称:名称下的说明和底部的保存失败都收起', await until(async () => await page.locator('#schedule-name-error').count() === 0 + && await page.locator('[data-schedule-save-error]').count() === 0)) + check('……名称框不再标成出错', await name.getAttribute('aria-invalid') === null) + await name.fill('X') + check('改回被拒的那个值:说明又出来(服务端说的就是这个值)', await until(async () => await page.locator('#schedule-name-error').count() === 1)) + + // 运行类别、补发宽限被拒:说明写在各自下面 + await name.fill('门店周报 · 每周') + reject('run_class', '运行类别需要选择正式运行或探索运行') + await page.locator('[data-run-class="exploratory"]').click() + await save.click() + const rc = await until(async () => (await page.locator('[data-run-class-error]').innerText().catch(() => '')) || null) + check('运行类别被拒:说明写在运行类别下面', !!rc && rc.includes('运行类别需要选择'), rc ?? '') + await page.locator('[data-run-class="formal"]').click() + check('……改了运行类别,说明收起', await until(async () => await page.locator('[data-run-class-error]').count() === 0)) + reject('misfire_grace_s', '补发宽限需要是 0 到 604800 之间的整数秒数(最长 7 天)') + await page.locator('[data-misfire]').selectOption('86400') + await save.click() + const ms = await until(async () => (await page.locator('#schedule-misfire-error').innerText().catch(() => '')) || null) + check('补发宽限被拒:说明写在补发宽限下面,选择框标成出错', !!ms && ms.includes('补发宽限') + && await page.locator('[data-misfire]').getAttribute('aria-invalid') === 'true', ms ?? '') + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('详情:保存途中又改了别的', async () => { + const { page, sent, errors, state } = await open('/schedules/s-weekly') + await page.locator('[data-schedule-form]').waitFor() + // PATCH 晚 1.2 秒才回,期间改名字 + state.override = async (method, path) => { + if (method === 'PATCH' && path === '/schedules/s-weekly') await sleep(1200) + return null + } + const SECRET_URL = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token-zz99' + await page.locator('[data-webhook-replace]').click() + await page.locator('[data-webhook-url]').fill(SECRET_URL) + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH /).length === 1) + await page.locator('[data-field="name"]').fill('途中改名') + await until(async () => !(await page.locator('[data-schedule-save]').innerText()).includes('正在保存'), 5000) + await sleep(200) + const values = await page.locator('[data-schedules-page] input').evaluateAll((els) => els.map((e) => e.value)) + check('保存后地址只显示 url_hint,输入框里不留含令牌的完整地址', (await text(page, '[data-webhook-url-hint]').catch(() => '')).includes('…zz99') + && !values.some((v) => v.includes('secret-token')), values.filter((v) => v.includes('http')).join(' | ')) + check('途中改的名字还在、仍算未保存', await page.locator('[data-field="name"]').inputValue() === '途中改名' + && !(await page.locator('[data-schedule-save]').isDisabled())) + check('……Webhook 这一块不算未保存:可以发测试消息', !(await page.locator('[data-webhook-test]').isDisabled())) + state.override = null + await page.locator('[data-schedule-save]').click() + await until(() => writes(sent, /^PATCH /).length === 2) + await saveDone(page) + const p2 = writes(sent, /^PATCH /)[1]?.body + check('下一次保存只交名字,不再带地址', JSON.stringify(p2) === JSON.stringify({ name: '途中改名', version: 4 }), JSON.stringify(p2)) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +await section('详情:晚到的刷新与先开触发历史', async () => { + const state = freshState() + const { page, sent, errors } = await open('/schedules/s-weekly', { state }) + await page.locator('[data-schedule-form]').waitFor() + const toggle = page.locator('header [data-schedule-toggle]') + state.override = slowGet(state, '/schedules/s-weekly', (st) => st.schedules.find((s) => s.id === 's-weekly')) + const gets = () => sent.filter((s) => s.key === 'GET /schedules/s-weekly').length + const before = gets() + // 立即运行之后刷新一次:读到开关之前的快照,晚 1.5 秒才回 + await page.locator('header [data-schedule-run]').click() + await until(() => gets() > before) + await until(async () => !(await page.locator('header [data-schedule-run]').isDisabled())) + await toggle.click() + check('页头开关先变成关', await until(async () => (await toggle.getAttribute('aria-checked')) === 'false')) + await sleep(1900) + check('晚到的刷新不把页头开关盖回去', await toggle.getAttribute('aria-checked') === 'false', `aria-checked=${await toggle.getAttribute('aria-checked')}`) + state.override = null + await toggle.click() + await until(() => writes(sent, /^PATCH /).length === 2) + const p = writes(sent, /^PATCH /).map((x) => x.body) + check('再点页头开关带开关之后的版本(4)', JSON.stringify(p[1]) === JSON.stringify({ enabled: true, version: 4 }), JSON.stringify(p)) + check('没有页面报错', !errors.length, errors.join(' | ')) + + // 先开触发历史,期间别处改过、页头刷新过;再切到设置:表单是刷新后的内容,保存就该带这一版 + const st2 = freshState() + const h = await open('/schedules/s-weekly?tab=history', { state: st2 }) + await h.page.locator('[data-fire-id]').first().waitFor() + st2.schedules = st2.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s)) + await h.page.locator('header [data-schedule-run]').click() + await until(async () => (await h.page.locator('[data-schedules-page] header').innerText()).includes('别处改过的名字')) + await h.page.locator('[role=tab]', { hasText: '设置' }).click() + await h.page.locator('[data-schedule-form]').waitFor() + check('切到设置:表单是刷新后的内容', await h.page.locator('[data-field="name"]').inputValue() === '别处改过的名字') + await h.page.locator('[data-misfire]').selectOption('86400') + await h.page.locator('[data-schedule-save]').click() + await until(() => writes(h.sent, /^PATCH /).length === 1) + await saveDone(h.page) + const hp = writes(h.sent, /^PATCH /)[0]?.body + check('保存带的是表单所基于的那一版(9),不撞假的 409', JSON.stringify(hp) === JSON.stringify({ misfire_grace_s: 86400, version: 9 }), JSON.stringify(hp)) + check('……没有冲突提示', await h.page.locator('[data-schedule-conflict]').count() === 0) + check('没有页面报错', !h.errors.length, h.errors.join(' | ')) +}) + +await section('详情:触发历史分页', async () => { + const { page, sent, errors } = await open('/schedules/s-weekly?tab=history') + await page.locator('[data-fire-id]').first().waitFor() + check('第一页 20 条', await page.locator('[data-fire-id]').count() === 20) + const first = sent.find((s) => s.key === 'GET /schedules/s-weekly/fires') + check('请求带 limit', !!first && new URLSearchParams(first.search).get('limit') === '20', first?.search ?? '') + const row = (id) => page.locator(`[data-fire-id="${id}"]`) + const t0 = await row('f00').innerText() + check('手动触发写「手动」、通知结果逐条写', t0.includes('手动') && t0.includes('运行结束') && t0.includes('已发送') && t0.includes('发送失败'), + t0.replace(/\s+/g, ' ')) + check('发起了的链到运行', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1) + const t1 = await row('f01').innerText() + check('上次未结束已跳过:写原因,没有运行链接', t1.includes('上次未结束已跳过') && t1.includes('#9f8e7d') && await row('f01').locator('a[href^="/runs/"]').count() === 0, + t1.replace(/\s+/g, ' ')) + check('补发写「补发」,运行状态照实写', (await row('f02').innerText()).includes('补发') && (await row('f02').innerText()).includes('失败')) + // 一行里给人看的字:正文连同各处悬停。错过、没有补发的原因只能是本条记的那一种,页面不能另替它认定一种 + const said = async (id) => [await row(id).innerText(), + ...await row(id).locator('[title]').evaluateAll((els) => els.map((e) => e.getAttribute('title')))].join(' | ').replace(/\s+/g, ' ') + for (const [id, label, own, other] of [['f03', '宽限内积压的', '积压的触发不补', '超出补发宽限'], ['f05', '超出宽限的', '超出补发宽限', '积压']]) { + const k = row(id).locator('[data-fire-kind-chip]') + const kt = (await k.getAttribute('title')) ?? '' + const t = await row(id).innerText() + check(`错过已跳过(${label}):写本条的原因`, t.includes('错过已跳过') && t.includes(own), t.replace(/\s+/g, ' ')) + check(`错过已跳过(${label}):类型写「定时」,悬停说没有补发、指向本条的原因,不说补发了一次`, (await k.innerText()).trim() === '定时' + && !kt.includes('补发的一次') && kt.includes('没有补发') && kt.includes('原因见本条记录'), `${await k.innerText()} | ${kt}`) + const all = await said(id) + check(`错过已跳过(${label}):整行连同悬停都不说另一种原因(${other})`, !all.includes(other), all) + } + const s6 = await said('f06') + check('错过已跳过、没有记原因:不写原因行,悬停不替它认定原因、不指向不存在的原因', s6.includes('错过已跳过') + && !s6.includes('原因:') && !s6.includes('超出补发宽限') && !s6.includes('积压') && !s6.includes('原因见'), s6) + const st6 = (await row('f06').locator('[data-fire-status-chip]').getAttribute('title')) ?? '' + check('……结局的悬停写这种结局的一般说明', st6.includes('错过') && st6.includes('没有发起'), st6) + const s7 = await said('f07') + const st7 = (await row('f07').locator('[data-fire-status-chip]').getAttribute('title')) ?? '' + check('发起失败、没有记原因:悬停说清没有原因,不指向不存在的原因', s7.includes('发起失败') && !s7.includes('原因:') + && !s7.includes('原因见') && st7.includes('未能发起运行') && st7.includes('未提供原因'), s7) + check('发起失败写原因', (await row('f04').innerText()).includes('发起失败') && (await row('f04').innerText()).includes('还没有发布')) + const when = row('f05').locator('time') + check('计划时间悬停看时区', ((await when.getAttribute('title')) ?? '').includes('Asia/Shanghai'), (await when.getAttribute('title')) ?? '') + const shownAt = await page.locator('[data-fire-id] time').allInnerTexts() + const wantAt = makeFires().slice(0, 20).map((f) => zoned(Date.parse(f.fire_at), 'Asia/Shanghai')) + check(`计划时间按定时任务的时区(Asia/Shanghai,不是本机的 ${LOCAL_TZ})写:20 行逐条对得上、都是周一 09:00`, + shownAt.length === 20 && shownAt.every((t, i) => t === wantAt[i]) && wantAt.every((w) => w.endsWith('周一 09:00')), + shownAt.map((t, i) => (t === wantAt[i] ? null : `${t} ≠ ${wantAt[i]}`)).filter(Boolean).slice(0, 3).join(';')) + await page.locator('[data-fire-more]').click() + await until(async () => (await page.locator('[data-fire-id]').count()) === 25) + const more = sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1) + check('加载更多:带上一页给的 before', new URLSearchParams(more?.search ?? '').get('before') === makeFires()[19].fire_at, more?.search ?? '') + check('……接在后面,共 25 条、写已全部加载', await page.locator('[data-fire-id]').count() === 25 + && await page.locator('[data-fire-more]').count() === 0 && (await text(page, '[data-fire-list]')).includes('已全部加载')) + check('没有重复的行', new Set(await page.locator('[data-fire-id]').evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25) + await page.locator('[role=tab]', { hasText: '设置' }).click() + check('切回设置:地址去掉 tab', await until(() => !new URL(page.url()).searchParams.has('tab'))) + const real = writes(sent, /./).filter((s) => s.key !== 'POST /schedules/preview') + check('只读:没有写请求', real.length === 0, real.map((s) => s.key).join('、')) + check('直接打开触发历史:不先把表单的预览请求一遍', !sent.slice(0, sent.findIndex((x) => x.key === 'GET /schedules/s-weekly/fires') + 1) + .some((x) => x.key === 'POST /schedules/preview')) + check('没有页面报错', !errors.length, errors.join(' | ')) + + const none = freshState({ fires: [] }) + const e = await open('/schedules/s-stock?tab=history', { state: none }) + await e.page.locator('[data-fire-empty]').waitFor() + check('没有触发记录:说清会记什么', (await text(e.page, '[data-fire-empty]')).includes('还没有触发记录')) + const gone = await open('/schedules/s-nope') + await gone.page.locator('[data-schedule-missing]').waitFor() + check('不存在的定时任务:说清并给返回列表', (await text(gone.page, '[data-schedule-missing]')).includes('不存在') + && await gone.page.locator('[data-schedule-missing] a[href="/schedules"]').count() === 1) +}) + +// ---------------------------------------------------------------- 记录页 + +await section('记录页:定时标记', async () => { + const { page, errors } = await open('/runs', { state: freshState({ runs: RUNS }) }) + await page.locator('[data-runs-list] [data-run-id="run-sched-1"]').waitFor({ timeout: 10000 }) + const tag = page.locator('[data-run-id="run-sched-1"] [data-run-schedule]') + check('定时任务发起的运行名字旁标「定时」', (await tag.innerText()).includes('定时')) + check('手动发起的不标', await page.locator('[data-run-id="run-manual-1"] [data-run-schedule]').count() === 0) + check('悬停写是哪个定时任务', await until(async () => ((await tag.getAttribute('title')) ?? '').includes('门店周报 · 每周')), + (await tag.getAttribute('title')) ?? '') + // 整行是打开运行的按钮,按钮里放不了可聚焦的链接:至少让读屏在这一行的名字里听到来源 + const rowName = page.getByRole('button', { name: /由定时任务「门店周报 · 每周」发起/ }) + check('读屏:整行按钮的名字里带上是哪个定时任务发起的', await until(async () => (await rowName.count()) === 1) + && (await rowName.getAttribute('data-run-id')) === 'run-sched-1') + await tag.click() + check('点标记去那个定时任务(不打开运行)', await until(() => new URL(page.url()).pathname === '/schedules/s-weekly'), page.url()) + check('没有页面报错', !errors.length, errors.join(' | ')) +}) + +// ---------------------------------------------------------------- 窄屏、浅色 + +await section('窄屏 390px 与浅色', async () => { + // 每一步另开一页:新建表单填了一半时 page.goto 会被离开前确认(beforeunload)拦下 + const narrow = { width: 390, height: 844 } + for (const theme of ['dark', 'light']) { + const list = await open('/schedules', { ...narrow, theme }) + await list.page.locator('[data-schedule-row]').first().waitFor() + // 外壳按设置换主题、开关的颜色过渡都要一点时间 + await sleep(400) + check(`${theme}:列表不横向溢出`, await overflow(list.page) <= 1, `${await overflow(list.page)}px`) + check(`${theme}:每行的立即运行、删除在屏幕里`, await inViewX(list.page, '[data-schedule-row="s-weekly"] [data-schedule-run]') + && await inViewX(list.page, '[data-schedule-row="s-weekly"] [data-schedule-delete]')) + const colors = await list.page.evaluate(() => { + const row = document.querySelector('[data-schedule-row]') + const track = document.querySelector('[data-schedule-toggle][aria-checked="true"] [data-switch-track]') + const probe = document.createElement('div') + probe.style.background = 'var(--accent-solid)' + document.body.appendChild(probe) + const accent = getComputedStyle(probe).backgroundColor + probe.remove() + return { + theme: document.documentElement.dataset.theme, fg: getComputedStyle(row).color, bg: getComputedStyle(row).backgroundColor, + track: track && getComputedStyle(track).backgroundColor, accent, + } + }) + check(`${theme}:页面是${theme === 'dark' ? '深色' : '浅色'},开着的开关是强调色,行里字和底色分得开`, + colors.theme === theme && colors.track === colors.accent && colors.fg !== colors.bg, JSON.stringify(colors)) + await shot(list.page, `list-390-${theme}`) + + const form = await open('/schedules/new', { ...narrow, theme }) + await form.page.locator('[data-schedule-form]').waitFor() + await fillWeekly(form.page) + await form.page.locator('[data-webhook-on]').check() + await previewSays(form.page, '每周一 09:00') + check(`${theme}:新建表单不横向溢出`, await overflow(form.page) <= 1, `${await overflow(form.page)}px`) + check(`${theme}:保存按钮、预览、Webhook 地址在屏幕里`, await inViewX(form.page, '[data-schedule-save]') + && await inViewX(form.page, '[data-schedule-preview]') && await inViewX(form.page, '[data-webhook-url]')) + const order = await form.page.evaluate(() => { + const y = (s) => document.querySelector(s)?.getBoundingClientRect().top ?? -1 + return { freq: y('[data-freq-preset]'), preview: y('[data-schedule-preview]'), inputs: y('[data-input-field]') } + }) + check(`${theme}:窄屏上预览排在频率下面、输入上面`, order.freq < order.preview && order.preview < order.inputs, JSON.stringify(order)) + await shot(form.page, `new-390-${theme}`) + // 换成没发布的库存日报、选正式运行:运行类别下面的说明、必填说明在窄屏上也排得开 + await form.page.locator('[data-workflow-picker] input').click() + await form.page.locator('[data-workflow-option="wf-stock"]').click() + await form.page.locator('[data-run-class="formal"]').click() + await form.page.locator('[data-run-class-problem]').waitFor() + check(`${theme}:运行类别的问题说明不横向溢出、在屏幕里`, await overflow(form.page) <= 1 + && await inViewX(form.page, '[data-run-class-problem]'), `${await overflow(form.page)}px`) + const tone = await form.page.evaluate(() => { + const el = document.querySelector('[data-run-class-problem]') + return { fg: getComputedStyle(el).color, bg: getComputedStyle(document.querySelector('[data-schedule-form] section')).backgroundColor } + }) + check(`${theme}:问题说明的字和底色分得开`, tone.fg !== tone.bg, JSON.stringify(tone)) + await form.page.locator('[data-run-class-problem]').scrollIntoViewIfNeeded() + await shot(form.page, `new-390-problem-${theme}`) + + const hist = await open('/schedules/s-weekly?tab=history', { ...narrow, theme }) + await hist.page.locator('[data-fire-id]').first().waitFor() + check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`) + check(`${theme}:页头的开关、立即运行、删除在屏幕里`, await inViewX(hist.page, 'header [data-schedule-run]') + && await inViewX(hist.page, 'header [data-schedule-toggle]') && await inViewX(hist.page, 'header [data-schedule-delete]')) + await shot(hist.page, `history-390-${theme}`) + const errors = [...list.errors, ...form.errors, ...hist.errors] + check(`${theme}:没有页面报错`, !errors.length, errors.join(' | ')) + } +}) + +await browser.close() +console.log(failed ? `\n✗ 定时任务:${failed} 项未通过` : '\n✓ 定时任务:全部通过') +process.exit(failed ? 1 : 0) diff --git a/scripts/copy-allow.json b/scripts/copy-allow.json index 2a9609ec..964b664f 100644 --- a/scripts/copy-allow.json +++ b/scripts/copy-allow.json @@ -60,6 +60,12 @@ "text": "报告按 [[i:字段名]]", "rule": "半角标点", "reason": "[[i:字段名]] 是报告里引用运行输入的标记写法,冒号是语法的一部分,提示用户照原样写" + }, + { + "file": "frontend/src/lib/terms.ts", + "text": "钉钉", + "rule": "钉", + "reason": "「钉钉」是 Webhook 通知的产品名(定时任务的消息格式:通用 / 飞书 / 企业微信 / 钉钉),不是「钉住」的意思" } ] }