第 4 波契约骨架:频率选择抽成公共组件 components/FreqPicker.tsx(频率与时区的纯函数搬到 lib/freq.ts,pages/schedules/model.ts 原样再导出),定时维护和定时任务共用;前端类型加 ScheduleTargetCreate(建评测题集、数据源维护这两种非工作流目标),api.schedules.create 收它;设置 ledger 一组加 recall_limit(口径升版后每次最多重算几组,缺省 50,0 表示不自动重算);test_notify_events 的 PUSHED_THIS_ROUND 拆成一项一行、给 source.changed、ledger.recall 各留一个位置;check-all 预登记 catalog-changes、evals、ledger 三项检查(先放占位)

This commit is contained in:
ThearchyHelios
2026-10-16 19:09:47 +08:00
parent dd052f6d21
commit 45fab21072
13 changed files with 513 additions and 420 deletions
+17 -4
View File
@@ -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]:
+6 -2
View File
@@ -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):
+9 -2
View File
@@ -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
+2 -2
View File
@@ -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<Schedule>(at(id), opts),
create: (body: ScheduleCreate) => post<Schedule>('/schedules', body),
create: (body: ScheduleCreate | ScheduleTargetCreate) => post<Schedule>('/schedules', body),
/** 只带改了的字段和读到的版本;Webhook 的地址省略表示不改、空串表示删除 */
update: (id: string, body: SchedulePatch) => patch<Schedule>(at(id), body),
remove: (id: string) => del(at(id)),
+130
View File
@@ -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<Freq>) => 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 = (
<Field label={T.time} htmlFor={`${idPrefix}-time`}>
<input id={`${idPrefix}-time`} type="time" step={60} className="field w-32" value={f.time} required
onChange={(e) => setFreq({ time: e.target.value })} data-freq-time="" {...errorProps(true)} />
</Field>
)
return (
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby={`${idPrefix}-freq-title`}>
<h2 id={`${idPrefix}-freq-title`} className="text-xs font-semibold">{title}</h2>
<div role="radiogroup" aria-labelledby={`${idPrefix}-freq-title`} className="flex flex-wrap gap-1">
{FREQ_PRESETS.map((p) => (
<button key={p} type="button" {...radio(p)} onClick={() => pick(p)} data-freq-preset={p}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
f.preset === p ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{T.preset[p]}
</button>
))}
</div>
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
{f.preset === 'weekly' && (
<div className="min-w-0">
<div className="label" id={`${idPrefix}-weekdays`}>{T.weekdays}</div>
<div role="group" aria-labelledby={`${idPrefix}-weekdays`} aria-describedby={weekdayError ? errId : undefined}
className="flex flex-wrap gap-1">
{WEEKDAY_ORDER.map((d) => {
const on = f.weekdays.includes(d)
return (
<button key={d} type="button" aria-pressed={on} data-weekday={d}
onClick={() => setFreq({ weekdays: on ? f.weekdays.filter((x) => x !== d) : [...f.weekdays, d] })}
className={clsx('h-7 min-w-9 rounded-md border px-1.5 text-xs transition-colors',
on ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{T.weekdayShort[d]}
</button>
)
})}
</div>
</div>
)}
{f.preset === 'monthly' && (
<Field label={T.monthday} htmlFor={`${idPrefix}-monthday`} hint={f.monthday > 28 ? T.monthdayHint : undefined}>
<select id={`${idPrefix}-monthday`} className="field w-24" value={f.monthday} data-freq-monthday=""
aria-describedby={f.monthday > 28 ? `${idPrefix}-monthday-hint` : undefined}
onChange={(e) => setFreq({ monthday: Number(e.target.value) })}>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => <option key={d} value={d}>{T.monthdayOption(d)}</option>)}
</select>
</Field>
)}
{(f.preset === 'daily' || f.preset === 'weekly' || f.preset === 'monthly') && timeInput}
{f.preset === 'hourly' && (
<>
<Field label={T.everyHours} htmlFor={`${idPrefix}-every`}>
<select id={`${idPrefix}-every`} className="field w-32" value={f.everyHours} data-freq-every=""
onChange={(e) => setFreq({ everyHours: Number(e.target.value) })}>
{HOUR_STEPS.map((n) => <option key={n} value={n}>{T.everyHoursOption(n)}</option>)}
</select>
</Field>
<Field label={T.minute} htmlFor={`${idPrefix}-minute`}>
<input id={`${idPrefix}-minute`} type="number" min={0} max={59} step={1} className="field w-20" data-freq-minute=""
value={Number.isFinite(f.minute) ? f.minute : ''} {...errorProps(true)}
onChange={(e) => setFreq({ minute: e.target.value === '' ? Number.NaN : Number(e.target.value) })} />
</Field>
</>
)}
{f.preset === 'custom' && (
<Field label={T.cron} htmlFor={`${idPrefix}-cron`} hint={freqError ? undefined : T.cronHint} className="min-w-0 flex-1 basis-56">
<input id={`${idPrefix}-cron`} className="field mono" value={f.cron} placeholder="0 9 * * 1-5" autoComplete="off" spellCheck={false}
onChange={(e) => setFreq({ cron: e.target.value })} data-freq-cron=""
{...(freqError ? errorProps(true) : { 'aria-describedby': `${idPrefix}-cron-hint` })} />
</Field>
)}
</div>
{f.preset === 'hourly' && !freqError && (
<p className="text-2xs leading-relaxed text-faint">{T.hourlyHint(f.everyHours, f.minute)}</p>
)}
{freqError && (
<div id={errId} className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-freq-error="">{freqError}</div>
)}
<Field label={T.timezone} htmlFor={`${idPrefix}-timezone`} hint={T.timezoneHint} error={tzError}>
<select id={`${idPrefix}-timezone`} className="field" value={timezone} data-timezone=""
aria-invalid={!!tzError || undefined}
aria-describedby={tzError ? `${idPrefix}-timezone-error` : `${idPrefix}-timezone-hint`}
onChange={(e) => setTimezone(e.target.value)}>
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
</select>
</Field>
</section>
)
}
+295
View File
@@ -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<number>()
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<string>()
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<string, Intl.DateTimeFormat | null>()
const WEEKDAY_EN: Record<string, number> = { 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<string, string> = {}
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
}
+6 -119
View File
@@ -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')} />
</section>
<FreqSection form={form} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
</div>
<aside className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
@@ -275,119 +275,6 @@ function WorkflowField({ form, workflows, state, workflow, base, error, onPick,
// 频率与时区
// ---------------------------------------------------------------------------
function FreqSection({ form, setFreq, setTimezone, freqError, tzError }: {
form: ScheduleForm
setFreq: (patch: Partial<Freq>) => 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 = (
<Field label={T.time} htmlFor="schedule-time">
<input id="schedule-time" type="time" step={60} className="field w-32" value={f.time} required
onChange={(e) => setFreq({ time: e.target.value })} data-freq-time="" {...errorProps(true)} />
</Field>
)
return (
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-freq-title">
<h2 id="schedule-freq-title" className="text-xs font-semibold">{T.freq}</h2>
<div role="radiogroup" aria-labelledby="schedule-freq-title" className="flex flex-wrap gap-1">
{FREQ_PRESETS.map((p) => (
<button key={p} type="button" {...radio(p)} onClick={() => pick(p)} data-freq-preset={p}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
f.preset === p ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{T.preset[p]}
</button>
))}
</div>
<div className="flex flex-wrap items-start gap-x-4 gap-y-3">
{f.preset === 'weekly' && (
<div className="min-w-0">
<div className="label" id="schedule-weekdays">{T.weekdays}</div>
<div role="group" aria-labelledby="schedule-weekdays" aria-describedby={weekdayError ? errId : undefined}
className="flex flex-wrap gap-1">
{WEEKDAY_ORDER.map((d) => {
const on = f.weekdays.includes(d)
return (
<button key={d} type="button" aria-pressed={on} data-weekday={d}
onClick={() => setFreq({ weekdays: on ? f.weekdays.filter((x) => x !== d) : [...f.weekdays, d] })}
className={clsx('h-7 min-w-9 rounded-md border px-1.5 text-xs transition-colors',
on ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{T.weekdayShort[d]}
</button>
)
})}
</div>
</div>
)}
{f.preset === 'monthly' && (
<Field label={T.monthday} htmlFor="schedule-monthday" hint={f.monthday > 28 ? T.monthdayHint : undefined}>
<select id="schedule-monthday" className="field w-24" value={f.monthday} data-freq-monthday=""
aria-describedby={f.monthday > 28 ? 'schedule-monthday-hint' : undefined}
onChange={(e) => setFreq({ monthday: Number(e.target.value) })}>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => <option key={d} value={d}>{T.monthdayOption(d)}</option>)}
</select>
</Field>
)}
{(f.preset === 'daily' || f.preset === 'weekly' || f.preset === 'monthly') && timeInput}
{f.preset === 'hourly' && (
<>
<Field label={T.everyHours} htmlFor="schedule-every">
<select id="schedule-every" className="field w-32" value={f.everyHours} data-freq-every=""
onChange={(e) => setFreq({ everyHours: Number(e.target.value) })}>
{HOUR_STEPS.map((n) => <option key={n} value={n}>{T.everyHoursOption(n)}</option>)}
</select>
</Field>
<Field label={T.minute} htmlFor="schedule-minute">
<input id="schedule-minute" type="number" min={0} max={59} step={1} className="field w-20" data-freq-minute=""
value={Number.isFinite(f.minute) ? f.minute : ''} {...errorProps(true)}
onChange={(e) => setFreq({ minute: e.target.value === '' ? Number.NaN : Number(e.target.value) })} />
</Field>
</>
)}
{f.preset === 'custom' && (
<Field label={T.cron} htmlFor="schedule-cron" hint={freqError ? undefined : T.cronHint} className="min-w-0 flex-1 basis-56">
<input id="schedule-cron" className="field mono" value={f.cron} placeholder="0 9 * * 1-5" autoComplete="off" spellCheck={false}
onChange={(e) => setFreq({ cron: e.target.value })} data-freq-cron=""
{...(freqError ? errorProps(true) : { 'aria-describedby': 'schedule-cron-hint' })} />
</Field>
)}
</div>
{f.preset === 'hourly' && !freqError && (
<p className="text-2xs leading-relaxed text-faint">{T.hourlyHint(f.everyHours, f.minute)}</p>
)}
{freqError && (
<div id={errId} className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-freq-error="">{freqError}</div>
)}
<Field label={T.timezone} htmlFor="schedule-timezone" hint={T.timezoneHint} error={tzError}>
<select id="schedule-timezone" className="field" value={form.timezone} data-timezone=""
aria-invalid={!!tzError || undefined}
aria-describedby={tzError ? 'schedule-timezone-error' : 'schedule-timezone-hint'}
onChange={(e) => setTimezone(e.target.value)}>
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
</select>
</Field>
</section>
)
}
// ---------------------------------------------------------------------------
// 预览
// ---------------------------------------------------------------------------
+5 -291
View File
@@ -1,289 +1,15 @@
/**
* 定时任务页的纯函数:频率预设和 cron 互转、按定时任务的时区写时间、表单和请求体互转(含保存回来怎么接)、
* 输入字段的对照、表单的问题、测试消息的结果。不碰 React、不发请求,scripts/check-schedules.mjs 直接借 vite
* 转译后调用它们。
*
* 时间一律按定时任务自己的时区写(「每周一 09:00(Asia/Shanghai)」说的就是那个时区的 09:00),本机时区不同时
* 在悬停提示里另给本机时间:只按本机写,人在外地打开就会以为周报改成了凌晨三点发。
* 定时任务页的纯函数:表单和请求体互转(含保存回来怎么接)、输入字段的对照、表单的问题、测试消息的结果。频率预设和
* cron 互转、按定时任务的时区写时间在 lib/freq.ts(数据源的定时维护也用),这里原样再导出。不碰 React、不发请求,
* scripts/check-schedules.mjs 直接借 vite 转译后调用它们。
*/
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<number>()
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<string>()
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<string, Intl.DateTimeFormat | null>()
const WEEKDAY_EN: Record<string, number> = { 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<string, string> = {}
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 * from '../../lib/freq'
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq'
// ---------------------------------------------------------------------------
// 表单 ⇄ 请求体
@@ -546,18 +272,6 @@ export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormC
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 地址用它;对外访问地址另有 lib/publicUrl 的 isPublicUrl) */
export function isHttpUrl(text: string): boolean {
+16
View File
@@ -3950,6 +3950,22 @@ export interface ScheduleCreate {
webhook: ScheduleWebhookIn | null
}
/**
* POST /api/schedules 建非工作流目标(评测题集、数据源维护,总方案 X4)的请求体:写 target_kind、target_id,不写工作流、
* 输入模板和 Webhook(服务端回 422,field 是那一项);运行类别不用。改的时候照样用 SchedulePatch(只带 name、cron、timezone、
* enabled、misfire_grace_s 这几项)
*/
export interface ScheduleTargetCreate {
name: string
target_kind: Exclude<ScheduleTargetKind, 'workflow'>
target_id: string
cron: string
timezone: string
enabled: boolean
overlap: 'skip'
misfire_grace_s: number
}
/** PATCH /api/schedules/{id} 的请求体:只带改了的字段,外加读到的版本 */
export type SchedulePatch = Partial<Omit<ScheduleCreate, 'webhook'>> & {
webhook?: ScheduleWebhookIn
+6
View File
@@ -83,6 +83,7 @@ const ORDER = [
'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409
'catalog-loop', // 数据目录闭环:表助手、审阅队列、导出导入、防错事实的说明
'catalog-profile', // 数据剖析:逐表的后台任务、表清单的剖析一列、表详情的一节、剖析设置
'catalog-changes', // 数据源的变化:审阅页「变化」标签页、数据源角标、表详情的「结构与取值变化」、定时维护、后台任务条
// 复核套件与导出
'schedules', // 定时任务
'compare', // 运行对比
@@ -92,6 +93,9 @@ const ORDER = [
'bundle', // 可验证报告
'whatif', // 数字推演
'report-export', // 报告导出:自包含 HTML、不执行脚本读得到正文、点开数字看出处
// 评测与口径账本(docs/design/next-round 01、02)
'evals', // 评测:题集、加题、发起与进度、结果表、对比与 A/B、口径命中与重复度
'ledger', // 口径账本:证据里的「口径」一步、轮次摘要与出具 / 对账、对账视图、认证队列
// 应用平台(docs/design/app-platform-and-look.md §4.5)。check-auth 不在这里:它要另起一套开着本地账号登录的服务,
// 由编排者单独跑(关登录时的这些检查照常在这里跑)
'apps', // 应用页、印章、点数字看出处、入口校验、实例页
@@ -119,6 +123,8 @@ const ESTIMATE = {
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
// 下一轮第 4 波:占位,换成真检查时按实测改
'catalog-changes': 60, evals: 90, ledger: 60,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// 数据源的变化与定时维护的界面检查(docs/design/next-round 03 B3、B4,05 (g))。
//
// 第 4 波骨架里的占位:check-all 先登记好名字,W4-d 换成真检查(假接口加夹具,照 check-share 的写法)。
console.log('=== 占位 ===')
console.log(' ✓ 占位:W4-d 换成真检查')
console.log('\n✓ 占位通过')
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// 评测页的界面检查(docs/design/next-round 01 第 18 项)。
//
// 第 4 波骨架里的占位:check-all 先登记好名字,W4-b 换成真检查(假接口加夹具,照 check-share 的写法)。
console.log('=== 占位 ===')
console.log(' ✓ 占位:W4-b 换成真检查')
console.log('\n✓ 占位通过')
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// 口径账本的界面检查(docs/design/next-round 02 做法 8)。
//
// 第 4 波骨架里的占位:check-all 先登记好名字,W4-c 换成真检查(假接口加夹具,照 check-share 的写法)。
console.log('=== 占位 ===')
console.log(' ✓ 占位:W4-c 换成真检查')
console.log('\n✓ 占位通过')