diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index ecbe29a2..c9b32957 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -741,11 +741,15 @@ async def apps_settings(session: AsyncSession) -> dict[str, Any]: #: 设置里 ledger 这一组。auto_issue:问数据的答案整题等价于受管口径(Agent 独立写的 SQL 被证明等价、版本受管、图里没有人工 #: 节点和写入)时自动按口径发起一次正式运行(拍板 L1;不满足的只给一键)。issue_daily_limit:每天最多按口径正式出具几次 #: (自动的、一键的合计,0 表示不再出具):正式运行含报告撰写的模型费用。match_timeout_s:一次运行的口径比对最多算几秒, -#: 超时的照实记没比完(ledger/match.py)。召回的上限在第 4 波加。只有管理员能改(整个设置接口的改动都要管理员) +#: 超时的照实记没比完(ledger/match.py)。recall_limit:口径发布新版本后,一次最多自动重算几组受影响的答案(02 做法 7、拍板 L6, +#: 缺省 50;超出的照实写「另有 M 组未重算」,可以手动接着算;0 表示发布后不自动重算,只列出受影响的答案)。只有管理员能改 +#: (整个设置接口的改动都要管理员) LEDGER_GROUP = "ledger" -LEDGER_DEFAULTS: dict[str, Any] = {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8} +LEDGER_DEFAULTS: dict[str, Any] = {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8, "recall_limit": 50} LEDGER_SETTING_LABEL = {"auto_issue": "自动按口径正式出具", "issue_daily_limit": "按口径正式出具每日上限", - "match_timeout_s": "口径比对时限(秒)"} + "match_timeout_s": "口径比对时限(秒)", "recall_limit": "口径升版后每次最多重算几组"} +#: 一次发布最多自动重算几组:召回只跑口径的数据部分、不调模型,但每组都查业务库 +LEDGER_RECALL_MAX = 1000 #: 时限的范围:太短比不完一份口径,太长占着比对的线程 LEDGER_TIMEOUT_RANGE = (1, 60) @@ -755,6 +759,10 @@ def _timeout_ok(value: Any) -> bool: and LEDGER_TIMEOUT_RANGE[0] <= value <= LEDGER_TIMEOUT_RANGE[1] +def _recall_limit_ok(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= LEDGER_RECALL_MAX + + def ledger_problem(value: Any) -> str | None: """设置页写进来的 ledger 这一组有什么问题,没问题返回 None。""" if not isinstance(value, dict): @@ -768,6 +776,9 @@ def ledger_problem(value: Any) -> str | None: if key == "issue_daily_limit" and not _daily_limit_ok(item): return (f"「{LEDGER_SETTING_LABEL[key]}」需要是 0 到 {APPS_DAILY_LIMIT_MAX:,} 之间的整数(0 表示不再按口径出具)," f"当前为「{item}」") + if key == "recall_limit" and not _recall_limit_ok(item): + return (f"「{LEDGER_SETTING_LABEL[key]}」需要是 0 到 {LEDGER_RECALL_MAX:,} 之间的整数(0 表示发布后不自动重算)," + f"当前为「{item}」") if key == "match_timeout_s" and not _timeout_ok(item): return (f"「{LEDGER_SETTING_LABEL[key]}」需要是 {LEDGER_TIMEOUT_RANGE[0]} 到 {LEDGER_TIMEOUT_RANGE[1]} 之间的数," f"当前为「{item}」") @@ -778,9 +789,11 @@ def ledger_values(stored: Any) -> dict[str, Any]: """库里存的(或刚写进来的)ledger 一组 → 补齐默认;存坏了的项按默认(自动出具的开关存坏了按关,宁可少出具)。""" stored = stored if isinstance(stored, dict) else {} auto, limit, timeout = stored.get("auto_issue"), stored.get("issue_daily_limit"), stored.get("match_timeout_s") + recall = stored.get("recall_limit") return {"auto_issue": auto if isinstance(auto, bool) else (LEDGER_DEFAULTS["auto_issue"] if auto is None else False), "issue_daily_limit": limit if _daily_limit_ok(limit) else LEDGER_DEFAULTS["issue_daily_limit"], - "match_timeout_s": timeout if _timeout_ok(timeout) else LEDGER_DEFAULTS["match_timeout_s"]} + "match_timeout_s": timeout if _timeout_ok(timeout) else LEDGER_DEFAULTS["match_timeout_s"], + "recall_limit": recall if _recall_limit_ok(recall) else LEDGER_DEFAULTS["recall_limit"]} async def ledger_settings(session: AsyncSession) -> dict[str, Any]: diff --git a/backend/tests/test_ledger_issue.py b/backend/tests/test_ledger_issue.py index d624fcf2..19803753 100644 --- a/backend/tests/test_ledger_issue.py +++ b/backend/tests/test_ledger_issue.py @@ -313,15 +313,19 @@ async def test_concurrent_requests_issue_once(monkeypatch): async def test_ledger_settings_have_defaults_and_refuse_bad_values(client): await reset_ledger() body = (await client.get("/api/settings")).json() - assert body["ledger"] == {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8} + assert body["ledger"] == {"auto_issue": True, "issue_daily_limit": 20, "match_timeout_s": 8, "recall_limit": 50} bad = await client.put("/api/settings", json={"values": {"ledger": {"auto_issue": "yes"}}}) assert bad.status_code == 422 and "自动按口径正式出具" in bad.json()["detail"] bad = await client.put("/api/settings", json={"values": {"ledger": {"match_timeout_s": 600}}}) assert bad.status_code == 422 and "口径比对时限" in bad.json()["detail"] + bad = await client.put("/api/settings", json={"values": {"ledger": {"recall_limit": -1}}}) + assert bad.status_code == 422 and "口径升版后每次最多重算几组" in bad.json()["detail"] ok = await client.put("/api/settings", json={"values": {"ledger": {"auto_issue": False, "issue_daily_limit": 5, "match_timeout_s": 3}}}) assert ok.status_code == 200 and ok.json()["ledger"] == {"auto_issue": False, "issue_daily_limit": 5, - "match_timeout_s": 3} + "match_timeout_s": 3, "recall_limit": 50} + ok = await client.put("/api/settings", json={"values": {"ledger": {"recall_limit": 0}}}) + assert ok.status_code == 200 and ok.json()["ledger"]["recall_limit"] == 0 async def test_auto_issue_goes_by_the_askers_permissions_when_login_is_on(monkeypatch): diff --git a/backend/tests/test_notify_events.py b/backend/tests/test_notify_events.py index 2e5cd722..4957bfb5 100644 --- a/backend/tests/test_notify_events.py +++ b/backend/tests/test_notify_events.py @@ -34,8 +34,15 @@ URL = "https://hooks.example.com/agentlab/notify-events-5d1c" THIS_ROUND = ("source.changed", "ledger.recall", "eval.regressed", "eval.failed", "task.digest", "key.expiring") #: 其中已经实现了推送、打开了订阅的(推给哪几条路):定时评测的两个只推全局渠道(第 3 波 W3-c,notify_eval);每天的待办汇总 #: 只推个人渠道;访问密钥快到期,个人密钥推本人、服务账号的推全局渠道(第 3 波 W3-d,notify/digest.py)。其余还没有 -PUSHED_THIS_ROUND = {"eval.regressed": {"global"}, "eval.failed": {"global"}, - "task.digest": {"user"}, "key.expiring": {"global", "user"}} +PUSHED_THIS_ROUND = { + "eval.regressed": {"global"}, + "eval.failed": {"global"}, + "task.digest": {"user"}, + "key.expiring": {"global", "user"}, + # 第 4 波:W4-d 打开 source.changed 时在这里加它推给的几条路 + # (两行之间隔一行不动的,两个包各改各的、合并不撞) + # 第 4 波:W4-a 打开 ledger.recall 时在这里加它推给的几条路 +} @pytest.fixture diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index d6af2afd..e5e505cc 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -9,7 +9,7 @@ */ import type { Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult, - ScheduleVariable, ScheduleWebhookTest, + ScheduleTargetCreate, ScheduleVariable, ScheduleWebhookTest, } from '../types' import { del, get, patch, post, qs, request, type RequestOptions } from './http' @@ -18,7 +18,7 @@ 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), + create: (body: ScheduleCreate | ScheduleTargetCreate) => post('/schedules', body), /** 只带改了的字段和读到的版本;Webhook 的地址省略表示不改、空串表示删除 */ update: (id: string, body: SchedulePatch) => patch(at(id), body), remove: (id: string) => del(at(id)), diff --git a/frontend/src/components/FreqPicker.tsx b/frontend/src/components/FreqPicker.tsx new file mode 100644 index 00000000..2da7c0da --- /dev/null +++ b/frontend/src/components/FreqPicker.tsx @@ -0,0 +1,130 @@ +import { useMemo } from 'react' +import clsx from 'clsx' +import { FREQ_PRESETS, HOUR_STEPS, WEEKDAY_ORDER, cronOf, timezoneOptions, type Freq, type FreqPreset } from '../lib/freq' +import { SCHEDULE_TEXT as T } from '../lib/terms' +import { Field, useRadioGroup } from './ui' + +/** + * 频率和时区的选择:预设(每天、每周、每月、每隔几小时)和自定义 cron,加时区。从定时任务的编辑表单抽出来,数据源的 + * 定时维护也用它(next-round 03 B4:维护就是一条目标为数据源的定时任务,频率的写法、说法和定时任务一样)。 + * + * 只管填:cron 合不合法、接下来几次什么时候由服务端的预览判(POST /api/schedules/preview),问题由调用方经 freqError / + * tzError 交进来,说明接到出问题的那一项上。 + */ +export function FreqPicker({ freq, timezone, setFreq, setTimezone, freqError, tzError, idPrefix = 'schedule', title = T.freq }: { + freq: Freq + timezone: string + setFreq: (patch: Partial) => void + setTimezone: (tz: string) => void + freqError?: string + tzError?: string + /** 元素 id 的前缀:同一页放两个频率选择时各给一个,免得 id 撞上(data-* 属性不带前缀,检查脚本照旧认) */ + idPrefix?: string + /** 这一节的标题,缺省「频率」 */ + title?: string +}) { + const f = 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 = `${idPrefix}-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(timezone), [timezone]) + const timeInput = ( + + setFreq({ time: e.target.value })} data-freq-time="" {...errorProps(true)} /> + + ) + + return ( +
+

{title}

+
+ {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': `${idPrefix}-cron-hint` })} /> + + )} +
+ {f.preset === 'hourly' && !freqError && ( +

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

+ )} + {freqError && ( + + )} + + + + +
+ ) +} diff --git a/frontend/src/lib/freq.ts b/frontend/src/lib/freq.ts new file mode 100644 index 00000000..822fc267 --- /dev/null +++ b/frontend/src/lib/freq.ts @@ -0,0 +1,295 @@ +/** + * 频率与时区的纯函数(定时任务和数据源的定时维护共用):频率预设和 cron 互转、按任务自己的时区写时间、补发宽限的写法、 + * 频率填得完不完整。不碰 React、不发请求。从 pages/schedules/model.ts 抽出来(next-round 03 B4:定时维护复用定时任务的 + * 频率选择),model.ts 原样再导出,老的引用不用改。 + * + * 时间一律按定时任务自己的时区写(「每周一 09:00(Asia/Shanghai)」说的就是那个时区的 09:00),本机时区不同时 + * 在悬停提示里另给本机时间:只按本机写,人在外地打开就会以为周报改成了凌晨三点发。 + */ +import { SCHEDULE_TEXT as T } from './terms' + +// --------------------------------------------------------------------------- +// 频率预设 ⇄ 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 + +/** 频率填得哪里不完整;完整时返回 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 +} diff --git a/frontend/src/pages/schedules/ScheduleEditor.tsx b/frontend/src/pages/schedules/ScheduleEditor.tsx index a3bcbbab..a38dd139 100644 --- a/frontend/src/pages/schedules/ScheduleEditor.tsx +++ b/frontend/src/pages/schedules/ScheduleEditor.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { Check } from 'lucide-react' import clsx from 'clsx' import { ApiError, api } from '../../api/client' +import { FreqPicker } from '../../components/FreqPicker' import { Field, Notice, Skeleton, Spinner, confirmDialog, toast, useRadioGroup } from '../../components/ui' import { errorMessage } from '../../lib/errors' import { parseServerTime } from '../../lib/format' @@ -11,9 +12,8 @@ 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, + GRACE_OPTIONS, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem, graceLabel, graphFields, patchBody, + rebaseForm, serverFieldValue, templateOf, type Freq, type FormField, type InputField, type ScheduleForm, } from './model' import { isUnsupported } from './parts' import { WebhookFields } from './WebhookFields' @@ -180,9 +180,9 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: { onPick={pickWorkflow} onBlur={() => touch('workflow')} /> - set({ timezone })} - freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} - tzError={previewSays('timezone') ?? serverError(['timezone'])} /> + set({ timezone })} + freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} + tzError={previewSays('timezone') ?? serverError(['timezone'])} />