mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:11:59 +00:00
定时任务页:列表、新建与编辑、触发历史、Webhook 设置与测试
占位页换成真页面。/schedules 是列表,/schedules/new 新建,/schedules/:id 是详情(设置、触发历史两个标签,?tab=history 直接打开触发历史);App 里只有带 id、不带 id 两条路由,「new」在页面里认。 列表每行写名字、工作流、频率的中文说法、下次运行(相对时间加按定时任务时区的绝对时间,悬停写时区,本机时区不同时另给本机时间)、上次触发(发起了的链到那次运行、写运行现在的状态;跳过、发起失败写结局,悬停写原因),右边启用开关(role=switch,点了就带版本 PATCH,409 时说服务端的原话并重新载入)、立即运行(发起了给「查看运行」直达,跳过、被拒照实说原因)、编辑、删除(站内确认框写清后果)。空态说明定时任务是什么、怎么建;老服务端没有接口时说暂不支持,不露 Not Found。列表 30 秒原地刷新一次。 表单:工作流可搜索(combobox,名字空着时用工作流名填上,新建时按发没发布替人选运行类别);频率五种预设生成 cron,切到自定义时带上当前预设的 cron;右侧(窄屏在频率下面)预览接下来 5 次触发,停手 400ms 再问、新请求作废旧请求,非法时清掉上一次的时间、把服务端的说明写在出错那一项下面并标 aria-invalid;时区默认本机、列常用时区和偏移;运行类别写清正式运行要已发布、每次按当时的发布版本发起;输入按运行类别取字段(正式运行取已发布版本的),每个字段可点变量插在光标处,旁边写按下次触发渲染出的结果;重叠策略和补发宽限;Webhook 的格式、地址、签名密钥、订阅事件、发送测试消息(只测已保存的设置,改了没保存时不让测),保存过的地址只显示 url_hint、可替换或删除,密钥只说「已设置」、可替换或清除;通知里运行链接用的服务访问地址读写设置的 notify 组。 保存只交改了的字段并带版本,409 时表单不动、给「重新载入」(有改动先确认);保存的这段时间里人又改了,留着新改的;有未保存的修改时离开先问。详情页的开关和表单各记版本:开关前后没有别人插进来时表单跟着用新版本,页头是在别人改过之后刷新出来的,表单照旧带旧版本、保存时由服务端说清。 触发历史 20 条一页、加载更多带 before 往后接(去重,从头重取时作废还在路上的那一页),写计划时间、类型(定时 / 手动 / 补发)、结局(已发起 / 上次未结束已跳过 / 错过已跳过 / 发起失败)、原因、运行链接和每条通知的结果。
This commit is contained in:
@@ -2741,8 +2741,6 @@ const PENDING_TEXT = '功能开发中'
|
||||
export const SCHEDULE_TEXT = {
|
||||
title: '定时任务',
|
||||
hint: '按设定的周期自动发起工作流运行',
|
||||
pending: PENDING_TEXT,
|
||||
pendingHint: '定时任务尚未开放,完成后可在此处管理',
|
||||
|
||||
// ---- 列表
|
||||
create: '新建定时任务',
|
||||
|
||||
@@ -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<ScheduleFire[] | null>(null)
|
||||
const [next, setNext] = useState<string | null>(null)
|
||||
const [error, setError] = useState<unknown>(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 ? <ErrorState error={error} onRetry={() => void load()} compact /> : <Skeleton rows={4} height={44} gap={8} />
|
||||
} else if (!items.length) {
|
||||
body = (
|
||||
<div data-fire-empty="">
|
||||
<EmptyState icon={<History size={22} />} title={T.historyEmpty} body={T.historyEmptyHint} offline={false} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<div data-fire-list="">
|
||||
<ul className="overflow-hidden rounded-lg border bg-panel">
|
||||
{items.map((f) => <FireRow key={f.id} fire={f} tz={schedule.timezone} now={now} />)}
|
||||
</ul>
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-3 text-2xs text-faint">
|
||||
<span className="tnum">{T.shown(items.length)}</span>
|
||||
{next ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void more()} disabled={loadingMore} data-fire-more="">
|
||||
{loadingMore && <Spinner size={11} />} {T.loadMore}
|
||||
</button>
|
||||
) : <span>· {T.allLoaded}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-4xl p-4" aria-labelledby="schedule-history-title">
|
||||
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="schedule-history-title" className="text-sm font-semibold">{T.history}</h2>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-faint">{T.historyHint}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void load()} data-fire-refresh="">
|
||||
<RotateCw size={11} aria-hidden /> {T.historyRefresh}
|
||||
</button>
|
||||
</div>
|
||||
{body}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) {
|
||||
const at = parseServerTime(f.fire_at)
|
||||
return (
|
||||
<li className="border-b px-3 py-2.5 last:border-b-0" data-fire-id={f.id} data-fire-status={f.status} data-fire-kind={f.kind}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="sr-only">{f.kind === 'manual' ? T.fireAtManual : T.fireAt}</span>
|
||||
{at ? <ZonedTime date={at} tz={tz} now={now} className="text-xs text-fg" /> : <span className="text-xs text-faint">—</span>}
|
||||
<FireKindChip fire={f} />
|
||||
<FireStatusChip fire={f} />
|
||||
<span className="flex-1" />
|
||||
{f.run_id ? (
|
||||
<Link to={`/runs/${encodeURIComponent(f.run_id)}`} className="inline-flex shrink-0 items-center gap-1.5 text-2xs text-dim hover:text-fg hover:underline">
|
||||
{f.run_status && <StatusPill status={f.run_status} plain />}
|
||||
{T.openRun}
|
||||
<ChevronRight size={11} aria-hidden />
|
||||
</Link>
|
||||
) : <span className="shrink-0 text-2xs text-faint">{T.noRun}</span>}
|
||||
</div>
|
||||
{f.reason && (
|
||||
<div className="mt-1 text-2xs leading-relaxed text-dim">
|
||||
<span className="text-faint">{T.reason}:</span>{f.reason}
|
||||
</div>
|
||||
)}
|
||||
{f.notified?.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-2xs">
|
||||
<span className="text-faint">{T.notified}</span>
|
||||
{f.notified.map((n, i) => (
|
||||
<span key={`${n.event}-${i}`} title={formatDateTime(n.at)}
|
||||
style={{ color: n.status === 'ok' ? 'var(--text-dim)' : 'var(--st-failed)' }} data-fire-notice={n.status}>
|
||||
{T.event[n.event] ?? n.event} {T.noticeStatus[n.status] ?? n.status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 = (
|
||||
<Link to="/schedules" className="btn btn-sm btn-ghost" aria-label={T.back} title={T.back} data-schedule-back="">
|
||||
<ArrowLeft size={12} aria-hidden /> <span className="hidden md:inline">{T.back}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
/** 新建定时任务(/schedules/new)。创建成功进它的详情页,替换掉这一条历史:后退不回到一张空表单 */
|
||||
export function ScheduleCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-schedules-page="new">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={T.newTitle} subtitle={T.hint} actions={BACK} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ScheduleEditor
|
||||
schedule={null}
|
||||
onSaved={(s) => {
|
||||
rememberScheduleNames([s])
|
||||
toast.ok(T.created(s.name))
|
||||
// 表单刚保存完、离开守卫还没来得及撤:这一跳带通行证
|
||||
navigate(`/schedules/${encodeURIComponent(s.id)}`, leavePass({ replace: true }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TabKey = 'settings' | 'history'
|
||||
|
||||
/**
|
||||
* 一个定时任务的详情(/schedules/:id):页头是名字、频率和启用开关、立即运行、删除;下面两个标签,设置(表单)
|
||||
* 和触发历史(?tab=history)。切到触发历史时表单不卸载,改到一半的内容还在。
|
||||
*
|
||||
* 两份状态分开记:schedule 是页头和摘要显示的、随时可以刷新的最新值;version 是表单保存时带的版本,只跟着自己的
|
||||
* 写操作(开关、保存)和明确的「重新载入」走。别人在这期间改过时表单保存就该 409,不能因为页头顺手刷新过就悄悄覆盖
|
||||
*/
|
||||
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<Schedule | null>(null)
|
||||
const [version, setVersion] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<unknown>(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 | 'toggle' | 'run' | 'delete'>(null)
|
||||
const now = useTicker(30_000)
|
||||
const loaded = useRef(false)
|
||||
|
||||
/** 读最新的定时任务,读到了返回 true。adopt:表单保存时也改用它的版本(首次打开、重新载入) */
|
||||
const load = useCallback(async (adopt: boolean): Promise<boolean> => {
|
||||
try {
|
||||
const s = await api.schedules.get(id)
|
||||
rememberScheduleNames([s])
|
||||
setSchedule(s)
|
||||
setError(null)
|
||||
if (adopt) setVersion(s.version)
|
||||
loaded.current = true
|
||||
return true
|
||||
} catch (e) {
|
||||
// 读到过之后刷新失败:页头照旧显示手上的,只在首次打开时整页报错
|
||||
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])
|
||||
|
||||
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<void>) => {
|
||||
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) {
|
||||
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 = (
|
||||
<div data-schedule-missing="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.notFound} body={T.notFoundHint} offline={false}
|
||||
action={<Link to="/schedules" className="btn btn-sm">{T.back}</Link>} />
|
||||
</div>
|
||||
)
|
||||
} else if (isUnsupported(error)) {
|
||||
content = <EmptyState icon={<CalendarClock size={22} />} title={T.unsupported} body={T.unsupportedHint} offline={false} />
|
||||
} else if (!schedule) {
|
||||
content = error
|
||||
? <ErrorState error={error} onRetry={() => void load(true)} />
|
||||
: <div className="mx-auto max-w-5xl p-4"><Skeleton rows={6} height={40} gap={10} /></div>
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{schedule && (
|
||||
<>
|
||||
<EnableSwitch checked={schedule.enabled} busy={busy === 'toggle'} label={T.enableLabel(schedule.name)} onChange={(v) => void toggle(v)} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => void run()} disabled={!!busy}
|
||||
aria-label={T.runNowLabel(schedule.name)} title={T.runNowLabel(schedule.name)} data-schedule-run="">
|
||||
{busy === 'run' ? <Spinner size={11} /> : <Play size={11} aria-hidden />}
|
||||
<span className="hidden sm:inline">{T.runNow}</span>
|
||||
</button>
|
||||
<IconButton label={T.deleteLabel(schedule.name)} onClick={() => void remove()} disabled={!!busy} data-schedule-delete=""
|
||||
className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={<Trash2 size={12} />} />
|
||||
</>
|
||||
)}
|
||||
{BACK}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" data-schedules-page="detail">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={schedule?.name ?? T.title} subtitle={schedule?.description_text}
|
||||
actions={actions} />
|
||||
{content ?? (schedule && (
|
||||
<>
|
||||
<div className="flex shrink-0 flex-wrap gap-x-5 gap-y-1 border-b bg-panel px-4 py-2 text-2xs" data-schedule-summary="">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0 text-faint">{T.nextRun}</span>
|
||||
<NextRun schedule={schedule} now={now} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0 text-faint">{T.lastFire}</span>
|
||||
<LastFire fire={schedule.last_fire} tz={schedule.timezone} now={now} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs tabs={[{ key: 'settings', label: T.tabs.settings }, { key: 'history', label: T.tabs.history }]}
|
||||
active={tab} onChange={pickTab} label={T.title} idPrefix="schedule" />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{settingsSeen && (
|
||||
<div role="tabpanel" id="schedule-panel-settings" aria-labelledby="schedule-tab-settings" hidden={tab !== 'settings'}>
|
||||
<ScheduleEditor
|
||||
key={resetKey}
|
||||
schedule={schedule}
|
||||
version={version}
|
||||
onSaved={(s) => {
|
||||
rememberScheduleNames([s])
|
||||
setSchedule(s)
|
||||
setVersion(s.version)
|
||||
}}
|
||||
onReload={() => void reload()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{historySeen && (
|
||||
<div role="tabpanel" id="schedule-panel-history" aria-labelledby="schedule-tab-history" hidden={tab !== 'history'}>
|
||||
<FireHistory schedule={schedule} refreshKey={historyKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
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 { 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, WorkflowVersion,
|
||||
} from '../../types'
|
||||
import {
|
||||
FREQ_PRESETS, GRACE_OPTIONS, HOUR_STEPS, WEEKDAY_ORDER, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem,
|
||||
graceLabel, patchBody, templateOf, timezoneOptions, type Freq, type FormField, type FreqPreset, 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 | null>(schedule)
|
||||
const [initial, setInitial] = useState<ScheduleForm>(() => formOf(schedule))
|
||||
const [form, setForm] = useState<ScheduleForm>(initial)
|
||||
const [touched, setTouched] = useState<Partial<Record<FormField, boolean>>>({})
|
||||
/** 新建时没动过运行类别:选工作流时按它发没发布替人选上 */
|
||||
const runClassTouched = useRef(!!schedule)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<{ message: string; detail?: string; conflict?: boolean; field?: string } | 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 problems = formProblems(form, base)
|
||||
const changes = base ? patchBody(form, initial, base) : null
|
||||
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<ScheduleForm>) => setForm((f) => ({ ...f, ...patch }))
|
||||
const setFreq = (patch: Partial<Freq>) => setForm((f) => ({ ...f, freq: { ...f.freq, ...patch } }))
|
||||
const touch = (k: FormField) => setTouched((t) => (t[k] ? t : { ...t, [k]: true }))
|
||||
|
||||
const pickWorkflow = (w: Workflow) => setForm((f) => {
|
||||
if (w.id === f.workflowId) return 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: f.name.trim() ? f.name : w.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) => (cur === sent ? next : cur))
|
||||
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 })
|
||||
} 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 serverError = (keys: string[]) => (saveError?.field && keys.includes(saveError.field) ? saveError.message : undefined)
|
||||
const shown = (k: FormField) => (touched[k] ? problems[k] : undefined)
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mx-auto grid max-w-5xl gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_300px]"
|
||||
noValidate
|
||||
onSubmit={(e) => { e.preventDefault(); void save() }}
|
||||
data-schedule-form=""
|
||||
>
|
||||
<div className="min-w-0 space-y-4 lg:col-start-1">
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5">
|
||||
<Field label={T.name} htmlFor="schedule-name" required error={shown('name') ?? serverError(['name'])}>
|
||||
<input id="schedule-name" className="field" value={form.name} placeholder={T.namePlaceholder} autoComplete="off"
|
||||
maxLength={200} aria-invalid={!!shown('name') || undefined}
|
||||
aria-describedby={shown('name') ? 'schedule-name-error' : undefined}
|
||||
onChange={(e) => set({ name: e.target.value })} onBlur={() => touch('name')} data-field="name" />
|
||||
</Field>
|
||||
<WorkflowField form={form} workflows={workflows} state={workflowsState} workflow={workflow} base={base}
|
||||
error={shown('workflow') ?? serverError(['workflow_id'])}
|
||||
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'])} />
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
|
||||
<div className="lg:sticky lg:top-4">
|
||||
<PreviewBox preview={preview} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 space-y-4 lg:col-start-1">
|
||||
<RunClassSection value={form.runClass} workflow={workflow}
|
||||
onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} />
|
||||
<InputsSection workflow={workflow} fields={fields} inputs={form.inputs} variables={variables}
|
||||
preview={preview}
|
||||
error={previewSays('input_template') ?? serverError(['input_template'])}
|
||||
onChange={(inputs) => set({ inputs })} />
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-overlap-title">
|
||||
<h2 id="schedule-overlap-title" className="text-xs font-semibold">{T.overlap}</h2>
|
||||
<p className="text-xs leading-relaxed text-dim">{T.overlapText}</p>
|
||||
<Field label={T.misfire} htmlFor="schedule-misfire" hint={T.misfireHint}>
|
||||
<select id="schedule-misfire" className="field w-auto max-w-full" value={form.misfireGraceS}
|
||||
aria-describedby="schedule-misfire-hint"
|
||||
onChange={(e) => set({ misfireGraceS: Number(e.target.value) })} data-misfire="">
|
||||
{[...new Set([...GRACE_OPTIONS, form.misfireGraceS])].sort((a, b) => a - b).map((s) => (
|
||||
<option key={s} value={s}>{T.misfireOption(graceLabel(s))}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{!base && (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => set({ enabled: e.target.checked })} data-enabled-on-create="" />
|
||||
{T.enabledOnCreate}
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
<WebhookFields value={form.webhook} onChange={(webhook) => set({ webhook })} saved={base?.webhook ?? null}
|
||||
problems={{ ...problems, url: problems.url ?? serverError(['webhook', 'webhook.url']) }}
|
||||
scheduleId={base?.id ?? null} unsaved={!!changes?.webhook} />
|
||||
</div>
|
||||
|
||||
{/* 有要保存的、或者有要看的报错时才贴在底部:没改动时一条空的保存栏压在表单上只是挡字 */}
|
||||
<div className={clsx('z-10 space-y-2 rounded-lg border bg-panel px-3 py-2.5 lg:col-span-2',
|
||||
(dirty || saveError) && 'sticky bottom-0 shadow-elev-2')} data-schedule-savebar="">
|
||||
{saveError?.conflict && (
|
||||
<Notice tone="err" attr={{ 'data-schedule-conflict': '' }}>
|
||||
<div>{saveError.message}</div>
|
||||
{saveError.detail && <div className="text-faint">{saveError.detail}</div>}
|
||||
<button type="button" className="btn btn-sm mt-1.5" onClick={() => void reload()} data-schedule-reload="">{T.reload}</button>
|
||||
</Notice>
|
||||
)}
|
||||
{saveError && !saveError.conflict && (
|
||||
<Notice tone="err" attr={{ 'data-schedule-save-error': '' }}>{T.saveFailed}:{saveError.message}</Notice>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{hint && <span className="mr-auto min-w-0 text-2xs leading-relaxed text-faint" data-schedule-hint="">{hint}</span>}
|
||||
{base
|
||||
? dirty && <button type="button" className="btn" onClick={discard} disabled={saving}>{T.discard}</button>
|
||||
: <Link to="/schedules" className="btn">{T.cancel}</Link>}
|
||||
<button type="submit" className="btn btn-primary" disabled={!canSave} title={hint || undefined} data-schedule-save="">
|
||||
{saving && <Spinner size={11} />} {saving ? T.saving : base ? T.save : T.createSubmit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作流
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 (
|
||||
<Field label={T.workflow} htmlFor="schedule-workflow" required error={message}
|
||||
hint={state === 'ok' && !workflows.length ? <>{T.workflowEmpty} <Link to="/studio" className="underline">{T.toStudio}</Link></> : undefined}>
|
||||
<WorkflowPicker id="schedule-workflow" value={form.workflowId} workflows={workflows} onChange={onPick} onBlur={onBlur}
|
||||
fallbackLabel={base?.workflow_name} invalid={!!message} describedBy={message ? 'schedule-workflow-error' : undefined} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 频率与时区
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 预览
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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<string, string>, workflowId: string): PreviewState {
|
||||
const [state, setState] = useState<PreviewState>({ 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 (
|
||||
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-preview-title" aria-live="polite"
|
||||
data-schedule-preview="" data-status={preview.status}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h2 id="schedule-preview-title" className="text-xs font-semibold">{T.preview}</h2>
|
||||
{preview.status === 'loading' && <Spinner size={11} />}
|
||||
</div>
|
||||
{data && (
|
||||
<>
|
||||
{data.description_text && <div className="mb-2 text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
|
||||
{data.next?.length ? (
|
||||
<ol className={clsx('space-y-1', preview.status === 'loading' && 'opacity-60')}>
|
||||
{data.next.map((at, i) => {
|
||||
const d = new Date(at)
|
||||
return (
|
||||
<li key={`${at}-${i}`} className="flex items-baseline gap-2 text-xs" data-preview-time="">
|
||||
<span className="tnum w-4 shrink-0 text-right text-faint">{i + 1}</span>
|
||||
<time className="tnum" dateTime={Number.isNaN(d.getTime()) ? undefined : d.toISOString()}>
|
||||
{tz ? formatZoned(d, tz, 'full') : at}
|
||||
</time>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : <p className="text-xs text-faint">{T.previewNone}</p>}
|
||||
{tz && <p className="mt-2 text-2xs text-faint">{T.previewZone(tz)}</p>}
|
||||
</>
|
||||
)}
|
||||
{!data && preview.status === 'loading' && <p className="text-xs text-faint">{T.previewLoading}</p>}
|
||||
{preview.status === 'idle' && <p className="text-xs text-faint">{T.previewIdle}</p>}
|
||||
{preview.status === 'invalid' && <p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">{preview.message}</p>}
|
||||
{preview.status === 'error' && (
|
||||
<p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">
|
||||
{isUnsupported(preview.error) ? T.unsupported : `${T.previewFailed}:${errorMessage(preview.error)}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 运行类别
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RUN_CLASSES: readonly ScheduleRunClass[] = ['formal', 'exploratory']
|
||||
|
||||
function RunClassSection({ value, workflow, onChange }: {
|
||||
value: ScheduleRunClass; workflow: Workflow | undefined; onChange: (v: ScheduleRunClass) => void
|
||||
}) {
|
||||
const radio = useRadioGroup(RUN_CLASSES, value, onChange)
|
||||
const unpublished = !!workflow && !workflow.published_version
|
||||
return (
|
||||
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-class-title">
|
||||
<h2 id="schedule-class-title" className="text-xs font-semibold">{T.runClass}</h2>
|
||||
<div role="radiogroup" aria-labelledby="schedule-class-title" className="grid gap-2 sm:grid-cols-2">
|
||||
{RUN_CLASSES.map((rc) => {
|
||||
const on = value === rc
|
||||
return (
|
||||
<button key={rc} type="button" {...radio(rc)} onClick={() => onChange(rc)} data-run-class={rc}
|
||||
className={clsx('relative flex flex-col justify-start rounded-lg border px-3 py-2.5 text-left transition-colors',
|
||||
on ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')}>
|
||||
{on && (
|
||||
<span className="absolute right-2 top-2 flex h-4 w-4 items-center justify-center rounded-full bg-accent-solid text-on-accent" aria-hidden>
|
||||
<Check size={10} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
<span className="pr-5 text-xs font-semibold">{rc === 'formal' ? T.formal : T.exploratory}</span>
|
||||
<span className="mt-1 text-2xs leading-relaxed text-faint">{rc === 'formal' ? T.formalHint : T.exploratoryHint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{value === 'formal' && unpublished && <Notice tone="warn" attr={{ 'data-not-published': '' }}>{T.notPublished}</Notice>}
|
||||
{value === 'formal' && workflow?.published_version != null && (
|
||||
<p className="text-2xs leading-relaxed text-faint">{T.formalVersion(workflow.published_version)}</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 输入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InputField = { name: string; required?: boolean; description?: string; default?: unknown }
|
||||
|
||||
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 versionFields = new Map<string, InputField[]>()
|
||||
|
||||
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<ScheduleVariable[]> | null = null
|
||||
function useVariables(): ScheduleVariable[] | null {
|
||||
const [vars, setVars] = useState<ScheduleVariable[] | null>(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, error, onChange }: {
|
||||
workflow: Workflow | undefined
|
||||
fields: FieldsState
|
||||
inputs: Record<string, string>
|
||||
variables: ScheduleVariable[] | null
|
||||
preview: PreviewState
|
||||
error?: string
|
||||
onChange: (inputs: Record<string, string>) => void
|
||||
}) {
|
||||
const refs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [open, setOpen] = useState<string | null>(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)) : []
|
||||
|
||||
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 = <p className="text-xs text-faint">{T.inputsPick}</p>
|
||||
else if (fields.status === 'loading') body = <Skeleton rows={2} height={28} />
|
||||
else if (fields.status === 'error') {
|
||||
body = (
|
||||
<Notice tone="err">
|
||||
{T.inputsFailed}:{errorMessage(fields.error)}
|
||||
<button type="button" className="btn btn-xs ml-2" onClick={fields.retry}>{T.retry}</button>
|
||||
</Notice>
|
||||
)
|
||||
} else if (!fields.fields.length && !stale.length) {
|
||||
body = <p className="text-xs text-faint">{T.inputsNone}</p>
|
||||
} else {
|
||||
body = (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div key={field.name} className="min-w-0" data-input-field={field.name}>
|
||||
<label className="label flex flex-wrap items-baseline gap-x-1.5" htmlFor={id}>
|
||||
<span className="mono text-fg">{field.name}</span>
|
||||
{field.required && <span className="text-[var(--err)]" aria-label={T.required} data-required="">*</span>}
|
||||
{field.description && <span className="font-normal text-faint">{field.description}</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id={id} ref={(el) => { 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} />
|
||||
<button type="button" className="btn btn-sm" aria-expanded={open === field.name} aria-controls={varsId}
|
||||
onClick={() => setOpen((o) => (o === field.name ? null : field.name))} data-input-vars={field.name}>
|
||||
{open === field.name ? T.variablesClose : T.variables}
|
||||
</button>
|
||||
</div>
|
||||
{open === field.name && (
|
||||
<div id={varsId} role="group" aria-label={T.variablesLabel(field.name)} className="mt-1.5 flex flex-wrap gap-1">
|
||||
{variables === null ? <Spinner size={11} /> : variables.length ? variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-xs" title={T.variableTitle(v.name, v.example)}
|
||||
onClick={() => insert(field.name, `{{ ${v.name} }}`)} data-var={v.name}>
|
||||
{v.label}
|
||||
</button>
|
||||
)) : <span className="text-2xs text-faint">{T.variablesEmpty}</span>}
|
||||
</div>
|
||||
)}
|
||||
{out != null && (
|
||||
<div id={`${id}-rendered`} className="mt-1 break-all text-2xs text-dim" data-input-rendered={field.name}>
|
||||
{out === '' ? T.renderedEmpty : T.rendered(out)}
|
||||
</div>
|
||||
)}
|
||||
{noDefault && !value.trim() && (
|
||||
<div id={`${id}-required`} className="mt-1 text-2xs" style={{ color: 'var(--warn)' }}>{T.requiredNoDefault}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{stale.map((name) => (
|
||||
<div key={name} className="flex min-w-0 flex-wrap items-center gap-1.5 text-2xs" data-input-stale={name}>
|
||||
<span className="mono text-dim">{name}</span>
|
||||
<span className="mono min-w-0 truncate text-faint">{inputs[name]}</span>
|
||||
<span style={{ color: 'var(--warn)' }}>{T.staleField}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={() => {
|
||||
const { [name]: _, ...rest } = inputs
|
||||
onChange(rest)
|
||||
}}>{T.removeField}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-inputs-title">
|
||||
<div>
|
||||
<h2 id="schedule-inputs-title" className="text-xs font-semibold">{T.inputs}</h2>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{T.inputsHint}</p>
|
||||
{workflow && fields.status === 'ok' && (
|
||||
<p className="mt-0.5 text-2xs text-faint" data-inputs-source="">
|
||||
{fields.version != null ? T.inputsFromVersion(fields.version) : T.inputsFromDraft}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{body}
|
||||
{error && <div className="text-2xs leading-relaxed text-[var(--err)]" role="alert" data-inputs-error="">{error}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,184 @@
|
||||
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, 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 <ScheduleListPage />
|
||||
if (id === 'new') return <ScheduleCreatePage />
|
||||
return <ScheduleDetailPage key={id} id={id} />
|
||||
}
|
||||
|
||||
/** 列表每 30 秒原地刷新一次(页面不可见时不刷):到点触发之后,下次运行和上次触发要跟着变 */
|
||||
const LIST_REFRESH_MS = 30_000
|
||||
|
||||
function ScheduleListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [rows, setRows] = useState<Schedule[] | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
/** 每行正在做的事:开关、立即运行、删除各自禁用,不让同一行连点出两个请求 */
|
||||
const [busy, setBusy] = useState<Record<string, 'toggle' | 'run' | 'delete'>>({})
|
||||
const now = useTicker(30_000)
|
||||
const seq = useRef(0)
|
||||
const hasRows = useRef(false)
|
||||
|
||||
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 (!(opts?.silent && hasRows.current)) setError(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<void>) => {
|
||||
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) replace(out)
|
||||
})
|
||||
const run = (s: Schedule) => act(s, 'run', async () => {
|
||||
const out = await runNow(s, navigate)
|
||||
if (!out) return
|
||||
replace({ ...s, last_fire: out.fire })
|
||||
// 下次运行、运行状态以服务端为准,再静默取一遍
|
||||
void load({ silent: true })
|
||||
})
|
||||
const remove = (s: Schedule) => act(s, 'delete', async () => {
|
||||
if (await removeSchedule(s)) setRows((rs) => rs && rs.filter((r) => r.id !== s.id))
|
||||
})
|
||||
|
||||
const unsupported = isUnsupported(error)
|
||||
const createButton = (
|
||||
<Link to="/schedules/new" className="btn btn-primary btn-sm" data-schedule-create="">
|
||||
<Plus size={12} aria-hidden /> {T.create}
|
||||
</Link>
|
||||
)
|
||||
|
||||
let body
|
||||
if (unsupported) {
|
||||
body = (
|
||||
<div data-schedules-unsupported="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.unsupported} body={T.unsupportedHint} offline={false} />
|
||||
</div>
|
||||
)
|
||||
} else if (rows === null) {
|
||||
body = error ? <ErrorState error={error} onRetry={() => void load()} /> : <Skeleton rows={3} height={64} gap={8} />
|
||||
} else if (!rows.length) {
|
||||
body = (
|
||||
<div data-schedules-empty="">
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={T.emptyTitle} body={T.emptyBody} action={createButton} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<ul className="space-y-2" aria-label={T.listLabel} data-schedule-list="">
|
||||
{rows.map((s) => (
|
||||
<ScheduleRow key={s.id} s={s} now={now} busy={busy[s.id]}
|
||||
onToggle={(v) => void toggle(s, v)} onRun={() => void run(s)} onDelete={() => void remove(s)} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={SCHEDULE_TEXT.title} subtitle={SCHEDULE_TEXT.hint} />
|
||||
<div className="flex h-full flex-col" data-schedules-page="list">
|
||||
<PageHeader icon={<CalendarClock size={13} />} title={T.title} subtitle={T.hint} actions={unsupported ? null : createButton} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */}
|
||||
<EmptyState icon={<CalendarClock size={22} />} title={SCHEDULE_TEXT.pending}
|
||||
body={SCHEDULE_TEXT.pendingHint} offline={false} />
|
||||
<div className="mx-auto max-w-4xl p-4">{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行:名字(链到详情)、工作流和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。
|
||||
* 窄屏上右边的按钮整组折到下一行,不挤名字
|
||||
*/
|
||||
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 (
|
||||
<li className="rounded-lg border bg-panel px-3 py-2.5" data-schedule-row={s.id} data-enabled={String(s.enabled)}>
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Link to={`/schedules/${encodeURIComponent(s.id)}`} data-schedule-name=""
|
||||
className="min-w-0 truncate text-sm font-medium text-fg hover:underline">
|
||||
{s.name}
|
||||
</Link>
|
||||
<ClassChip runClass={s.run_class} />
|
||||
{!s.enabled && <span className="chip shrink-0" data-schedule-paused="">{T.pausedChip}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-dim" title={`${s.workflow_name} · ${s.description_text}`} data-schedule-desc="">
|
||||
{s.workflow_name}<span className="text-faint"> · </span>{s.description_text}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<EnableSwitch checked={s.enabled} busy={busy === 'toggle'} label={T.enableLabel(s.name)} onChange={onToggle} />
|
||||
<button type="button" className="btn btn-sm" onClick={onRun} disabled={!!busy} aria-label={T.runNowLabel(s.name)}
|
||||
title={T.runNowLabel(s.name)} data-schedule-run="">
|
||||
{busy === 'run' ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {T.runNow}
|
||||
</button>
|
||||
<Link to={`/schedules/${encodeURIComponent(s.id)}`} className="btn btn-sm btn-ghost" aria-label={T.editLabel(s.name)}
|
||||
data-schedule-edit="">
|
||||
{T.edit}
|
||||
</Link>
|
||||
<IconButton label={T.deleteLabel(s.name)} onClick={onDelete} disabled={!!busy} data-schedule-delete=""
|
||||
className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={<Trash2 size={12} />} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1 text-2xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5" data-schedule-next="">
|
||||
<span className="shrink-0 text-faint">{T.nextRun}</span>
|
||||
<NextRun schedule={s} now={now} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5" data-schedule-last="">
|
||||
<span className="shrink-0 text-faint">{T.lastFire}</span>
|
||||
<LastFire fire={s.last_fire} tz={s.timezone} now={now} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, 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<Record<FormField, string>>
|
||||
/** 已保存的定时任务 id;新建时为 null,不能测试 */
|
||||
scheduleId: string | null
|
||||
/** Webhook 这一块有没保存的改动 */
|
||||
unsaved: boolean
|
||||
}) {
|
||||
const w = value
|
||||
const set = (patch: Partial<WebhookForm>) => 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 (
|
||||
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-webhook-title" data-webhook-section="">
|
||||
<h2 id="schedule-webhook-title" className="mb-2.5 text-xs font-semibold">{T.webhook}</h2>
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={w.on} onChange={(e) => set({ on: e.target.checked })} data-webhook-on="" />
|
||||
<span>{T.webhookOn}</span>
|
||||
</label>
|
||||
|
||||
{!w.on && saved && (
|
||||
<Notice tone="warn" className="mt-2.5" attr={{ 'data-webhook-removing': '' }}>
|
||||
<span>{T.webhookRemoving}</span>
|
||||
<button type="button" className="btn btn-xs ml-2" onClick={() => set({ on: true })}>{T.webhookUndoRemove}</button>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{w.on && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="label" id="schedule-webhook-format">{T.webhookFormat}</div>
|
||||
<div role="radiogroup" aria-labelledby="schedule-webhook-format" className="flex flex-wrap gap-1">
|
||||
{WEBHOOK_FORMATS.map((f) => (
|
||||
<button key={f} type="button" {...radio(f)} onClick={() => set({ format: f })} data-webhook-format={f}
|
||||
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
w.format === f ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{T.format[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{askUrl ? (
|
||||
<Field label={T.url} htmlFor="schedule-webhook-url" error={urlError} hint={T.formatHint[w.format]}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-webhook-url" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-52"
|
||||
value={w.url} placeholder={T.urlPlaceholder[w.format]} autoComplete="off" spellCheck={false}
|
||||
aria-invalid={!!urlError || undefined}
|
||||
aria-describedby={urlError ? 'schedule-webhook-url-error' : 'schedule-webhook-url-hint'}
|
||||
onChange={(e) => set({ url: e.target.value })} onBlur={() => setUrlTouched(true)} data-webhook-url="" />
|
||||
{saved && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => set({ replaceUrl: false, url: '' })}>{T.urlKeep}</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label">{T.urlSaved}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mono min-w-0 truncate rounded-md border bg-elev px-2 py-1 text-xs text-dim" title={T.urlSavedHint}
|
||||
data-webhook-url-hint="">
|
||||
{saved?.url_hint}
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => set({ replaceUrl: true, url: '' })} data-webhook-replace="">
|
||||
{T.urlReplace}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => set({ on: false })} data-webhook-remove="">
|
||||
{T.webhookRemove}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-2xs leading-relaxed text-faint">{T.urlSavedHint}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SecretField value={w} saved={saved} onChange={set} error={problems.secret} />
|
||||
|
||||
<fieldset>
|
||||
<legend className="label">{T.events}</legend>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{SCHEDULE_EVENTS.map((ev) => (
|
||||
<label key={ev} className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={w.events.includes(ev)} data-webhook-event={ev}
|
||||
onChange={(e) => set({ events: toggled(w.events, ev, e.target.checked) })} />
|
||||
{T.event[ev]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{problems.events && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problems.events}</div>}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => void sendTest()} disabled={!!testBlocked || test?.busy}
|
||||
title={testBlocked ?? undefined} data-webhook-test="">
|
||||
{test?.busy ? <Spinner size={11} /> : <Send size={11} aria-hidden />} {test?.busy ? T.testing : T.test}
|
||||
</button>
|
||||
{testBlocked && <span className="text-2xs text-faint">{testBlocked}</span>}
|
||||
</div>
|
||||
{test && !test.busy && (
|
||||
<Notice tone={test.result?.ok ? 'info' : 'err'} attr={{ 'data-webhook-test-result': test.result?.ok ? 'ok' : 'failed' }}>
|
||||
{test.result
|
||||
? (test.result.ok ? T.testOk : T.testFailed)(test.result.http_status, test.result.message)
|
||||
: T.testFailed(null, errorMessage(test.error))}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<LinkBase />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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<WebhookForm>) => void; error?: string
|
||||
}) {
|
||||
const hasSecret = !!saved?.has_secret
|
||||
if (hasSecret && w.secretMode === 'keep') {
|
||||
return (
|
||||
<div>
|
||||
<div className="label">{T.secret}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="chip" data-webhook-secret-set="">{T.secretSet}</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'replace', secret: '' })}>{T.secretReplace}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'clear', secret: '' })}>{T.secretClear}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hasSecret && w.secretMode === 'clear') {
|
||||
return (
|
||||
<div>
|
||||
<div className="label">{T.secret}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-dim">
|
||||
<span>{T.secretClearing}</span>
|
||||
<button type="button" className="btn btn-xs" onClick={() => onChange({ secretMode: 'keep' })}>{T.secretUndo}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Field label={hasSecret ? T.secretNew : T.secret} htmlFor="schedule-webhook-secret" error={error} hint={T.secretHint[w.format]}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-webhook-secret" type="password" className="field mono min-w-0 flex-1 basis-52" value={w.secret}
|
||||
autoComplete="new-password" spellCheck={false} aria-invalid={!!error || undefined}
|
||||
aria-describedby={error ? 'schedule-webhook-secret-error' : 'schedule-webhook-secret-hint'}
|
||||
onChange={(e) => onChange({ secret: e.target.value })} data-webhook-secret="" />
|
||||
{hasSecret && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({ secretMode: 'keep', secret: '' })}>{T.secretUndo}</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知里运行链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口,
|
||||
* 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
|
||||
*/
|
||||
function LinkBase() {
|
||||
const [value, setValue] = useState<string | null | undefined>(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 (
|
||||
<div className="rounded-md border bg-elev px-2.5 py-2" data-link-base="">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs">
|
||||
<span className="font-medium text-dim">{T.linkBase}</span>
|
||||
<span className={clsx('min-w-0 break-all', value ? 'mono text-fg' : 'text-faint')}>
|
||||
{failed ? T.linkBaseFailed : value === undefined ? <Spinner size={10} /> : value ? T.linkBaseValue(value) : T.linkBaseUnset}
|
||||
</span>
|
||||
{!editing && value !== undefined && (
|
||||
<button type="button" className="btn btn-xs" onClick={() => { setDraft(value ?? ''); setEditing(true) }} data-link-base-edit="">
|
||||
{value ? T.linkBaseEdit : T.linkBaseSet}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="mt-2">
|
||||
<Field label={T.linkBaseLabel} htmlFor="schedule-link-base" hint={T.linkBaseHint} error={invalid ? T.linkBaseInvalid : undefined}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="schedule-link-base" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-48" value={draft}
|
||||
placeholder={T.linkBasePlaceholder} autoComplete="off" spellCheck={false} aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? 'schedule-link-base-error' : 'schedule-link-base-hint'}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 在表单里:回车存这一项,不提交整张表单(输入法组字时的回车不算)
|
||||
if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() }
|
||||
}}
|
||||
data-link-base-input="" />
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()} disabled={invalid || busy} data-link-base-save="">
|
||||
{busy && <Spinner size={11} />} {T.save}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(false)}>{T.cancel}</button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="relative" data-workflow-picker="">
|
||||
<input
|
||||
id={id}
|
||||
className="field"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && matches[active] ? `${optionId}-${active}` : undefined}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
value={open ? query : selected?.name ?? (value ? fallbackLabel ?? '' : '')}
|
||||
placeholder={T.workflowPlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onFocus={show}
|
||||
onClick={() => { if (!open) show() }}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setActive(0)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onBlur?.()
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && (
|
||||
<ul id={listId} role="listbox" aria-label={T.workflow}
|
||||
className="absolute inset-x-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-md border bg-panel py-1 shadow-elev-2">
|
||||
{matches.length ? matches.map((w, i) => (
|
||||
<li
|
||||
key={w.id}
|
||||
id={`${optionId}-${i}`}
|
||||
role="option"
|
||||
aria-selected={w.id === value}
|
||||
data-workflow-option={w.id}
|
||||
onMouseDown={(e) => 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')}
|
||||
>
|
||||
<span className={clsx('min-w-0 flex-1 truncate', w.id === value && 'font-medium')}>{w.name}</span>
|
||||
<span className="chip shrink-0">{workflowStatusText(w)}</span>
|
||||
</li>
|
||||
)) : <li className="px-2.5 py-1.5 text-xs text-faint" role="presentation">{T.workflowNone}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
data-schedule-toggle=""
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-1 py-0.5 text-2xs text-dim outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
data-switch-track=""
|
||||
className="relative inline-block h-4 w-7 shrink-0 rounded-full transition-colors"
|
||||
style={{ background: checked ? 'var(--accent-solid)' : 'var(--border-strong)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 h-3 w-3 rounded-full shadow-elev-1 transition-[left]"
|
||||
style={{ left: checked ? 14 : 2, background: 'var(--on-accent)' }}
|
||||
/>
|
||||
</span>
|
||||
<span>{T.enable}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 时间
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按定时任务的时区写一个时刻;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 (
|
||||
<time dateTime={date.toISOString()} title={title} className={clsx('tnum', className)}>
|
||||
{rel ? <>{rel}<span className="text-faint"> · {abs}</span></> : abs}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */
|
||||
export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) {
|
||||
if (!schedule.enabled) return <span className="text-faint">{T.nextPaused}</span>
|
||||
const d = parseServerTime(schedule.next_run_at)
|
||||
if (!d) return <span className="text-faint">{T.nextNone}</span>
|
||||
return <ZonedTime date={d} tz={schedule.timezone} now={now} relative className="text-dim" />
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 触发
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 触发结局的颜色:发起了是安静的完成色,跳过是琥珀,发起失败是红 */
|
||||
const FIRE_TONE: Record<ScheduleFireStatus, string> = {
|
||||
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 (
|
||||
<span
|
||||
className="chip shrink-0"
|
||||
title={fire.reason || T.fireStatusHint[fire.status] || undefined}
|
||||
data-fire-status-chip={fire.status}
|
||||
style={{
|
||||
color,
|
||||
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
|
||||
background: fire.status === 'started' ? undefined : `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{T.fireStatus[fire.status] ?? fire.status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 触发类型:定时 / 手动 / 补发 */
|
||||
export function FireKindChip({ fire }: { fire: ScheduleFire }) {
|
||||
return (
|
||||
<span className="chip shrink-0" title={T.fireKindHint[fire.kind]} data-fire-kind-chip={fire.kind}>
|
||||
{T.fireKind[fire.kind] ?? fire.kind}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表里的「上次触发」:发起了运行的链到那次运行、写运行现在的状态;跳过、发起失败的写结局 */
|
||||
export function LastFire({ fire, tz, now }: { fire: ScheduleFire | null; tz: string; now: number }) {
|
||||
if (!fire) return <span className="text-faint">{T.neverFired}</span>
|
||||
const d = parseServerTime(fire.fire_at)
|
||||
const when = d ? <ZonedTime date={d} tz={tz} now={now} className="text-faint" /> : null
|
||||
if (fire.run_id) {
|
||||
return (
|
||||
<Link to={`/runs/${encodeURIComponent(fire.run_id)}`} className="inline-flex min-w-0 items-center gap-1.5 hover:underline">
|
||||
{fire.run_status ? <StatusPill status={fire.run_status} plain /> : <FireStatusChip fire={fire} />}
|
||||
{when}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<FireStatusChip fire={fire} />
|
||||
{when}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 列表行和详情页头共用的操作
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 去掉服务端原话末尾的句号,后面好接自己的话 */
|
||||
const clause = (text: string) => text.replace(/[。.!!]+$/, '')
|
||||
|
||||
/**
|
||||
* 启用 / 暂停:带读到的版本 PATCH,成功交回新的定时任务。版本不符(409)时说服务端的原话、返回 'conflict',
|
||||
* 由调用方重新载入;别的错误弹提示、返回 null
|
||||
*/
|
||||
export async function setEnabled(s: Schedule, enabled: boolean): Promise<Schedule | 'conflict' | null> {
|
||||
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(`${clause(e.message)}。${T.conflictReloaded}`)
|
||||
return 'conflict'
|
||||
}
|
||||
toast.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。返回这次触发(出错时 null),
|
||||
* 调用方拿它更新「上次触发」和触发历史
|
||||
*/
|
||||
export async function runNow(s: Schedule, navigate: (to: string) => void): Promise<ScheduleRunResult | null> {
|
||||
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<boolean> {
|
||||
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<string, string>()
|
||||
let fetchedAt = 0
|
||||
let inflight: Promise<void> | 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<Schedule, 'id' | 'name'>[], 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<string, string> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行名字旁的「定时」标记:悬停写是哪个定时任务,点一下去那个定时任务。记录页的整行是打开运行的按钮,
|
||||
* 这里拦住冒泡,只去定时任务、不打开运行
|
||||
*/
|
||||
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 (
|
||||
<span
|
||||
className="chip shrink-0 cursor-pointer hover:border-[var(--border-strong)] hover:text-fg"
|
||||
title={T.runTagTitle(name)}
|
||||
data-run-schedule={scheduleId}
|
||||
onClick={go}
|
||||
>
|
||||
<CalendarClock size={10} aria-hidden /> {T.runTag}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user