From e8cc4bd88bf8463a2a86af47c54e2feb4f44a24a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 08:56:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E9=A1=B5?= =?UTF-8?q?=EF=BC=9A=E5=88=97=E8=A1=A8=E3=80=81=E6=96=B0=E5=BB=BA=E4=B8=8E?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E3=80=81=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E3=80=81Webhook=20=E8=AE=BE=E7=BD=AE=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 占位页换成真页面。/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 往后接(去重,从头重取时作废还在路上的那一页),写计划时间、类型(定时 / 手动 / 补发)、结局(已发起 / 上次未结束已跳过 / 错过已跳过 / 发起失败)、原因、运行链接和每条通知的结果。 --- frontend/src/lib/terms.ts | 2 - frontend/src/pages/schedules/FireHistory.tsx | 139 ++++ .../src/pages/schedules/ScheduleDetail.tsx | 210 ++++++ .../src/pages/schedules/ScheduleEditor.tsx | 692 ++++++++++++++++++ .../src/pages/schedules/SchedulesPage.tsx | 183 ++++- .../src/pages/schedules/WebhookFields.tsx | 272 +++++++ .../src/pages/schedules/WorkflowPicker.tsx | 126 ++++ frontend/src/pages/schedules/parts.tsx | 284 +++++++ 8 files changed, 1897 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/schedules/FireHistory.tsx create mode 100644 frontend/src/pages/schedules/ScheduleDetail.tsx create mode 100644 frontend/src/pages/schedules/ScheduleEditor.tsx create mode 100644 frontend/src/pages/schedules/WebhookFields.tsx create mode 100644 frontend/src/pages/schedules/WorkflowPicker.tsx create mode 100644 frontend/src/pages/schedules/parts.tsx diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 537d183a..0476d596 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2741,8 +2741,6 @@ const PENDING_TEXT = '功能开发中' export const SCHEDULE_TEXT = { title: '定时任务', hint: '按设定的周期自动发起工作流运行', - pending: PENDING_TEXT, - pendingHint: '定时任务尚未开放,完成后可在此处管理', // ---- 列表 create: '新建定时任务', diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx new file mode 100644 index 00000000..3e008910 --- /dev/null +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { ChevronRight, History, RotateCw } from 'lucide-react' +import { api } from '../../api/client' +import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui' +import { formatDateTime, parseServerTime } from '../../lib/format' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { Schedule, ScheduleFire } from '../../types' +import { FireKindChip, FireStatusChip, ZonedTime } from './parts' + +const PAGE = 20 + +/** + * 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败 + * 写原因;发起了的链到那次运行、写运行现在的状态;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取 + */ +export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) { + const [items, setItems] = useState(null) + const [next, setNext] = useState(null) + const [error, setError] = useState(null) + const [loadingMore, setLoadingMore] = useState(false) + const now = useTicker(60_000) + // 从头重取时作废还在路上的「加载更多」:两份答复交错接上会重复、会漏 + const seq = useRef(0) + const id = schedule.id + + const load = useCallback(async () => { + const n = ++seq.current + try { + const page = await api.schedules.fires(id, { limit: PAGE }) + if (n !== seq.current) return + setItems(page.items ?? []) + setNext(page.next_before ?? null) + setError(null) + } catch (e) { + if (n === seq.current) setError(e) + } + }, [id]) + useEffect(() => { void load() }, [load, refreshKey]) + + const more = async () => { + if (!next || loadingMore) return + const n = seq.current + setLoadingMore(true) + try { + const page = await api.schedules.fires(id, { limit: PAGE, before: next }) + if (n !== seq.current) return + setItems((cur) => { + const seen = new Set((cur ?? []).map((f) => f.id)) + return [...(cur ?? []), ...(page.items ?? []).filter((f) => !seen.has(f.id))] + }) + setNext(page.next_before ?? null) + } catch (e) { + toast.error(e) + } finally { + setLoadingMore(false) + } + } + + let body + if (items === null) { + body = error ? void load()} compact /> : + } else if (!items.length) { + body = ( +
+ } title={T.historyEmpty} body={T.historyEmptyHint} offline={false} /> +
+ ) + } else { + body = ( +
+
    + {items.map((f) => )} +
+
+ {T.shown(items.length)} + {next ? ( + + ) : · {T.allLoaded}} +
+
+ ) + } + + return ( +
+
+
+

{T.history}

+

{T.historyHint}

+
+ +
+ {body} +
+ ) +} + +function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) { + const at = parseServerTime(f.fire_at) + return ( +
  • +
    + {f.kind === 'manual' ? T.fireAtManual : T.fireAt} + {at ? : —} + + + + {f.run_id ? ( + + {f.run_status && } + {T.openRun} + + + ) : {T.noRun}} +
    + {f.reason && ( +
    + {T.reason}:{f.reason} +
    + )} + {f.notified?.length > 0 && ( +
    + {T.notified} + {f.notified.map((n, i) => ( + + {T.event[n.event] ?? n.event} {T.noticeStatus[n.status] ?? n.status} + + ))} +
    + )} +
  • + ) +} diff --git a/frontend/src/pages/schedules/ScheduleDetail.tsx b/frontend/src/pages/schedules/ScheduleDetail.tsx new file mode 100644 index 00000000..653646f8 --- /dev/null +++ b/frontend/src/pages/schedules/ScheduleDetail.tsx @@ -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 = ( + + {T.back} + +) + +/** 新建定时任务(/schedules/new)。创建成功进它的详情页,替换掉这一条历史:后退不回到一张空表单 */ +export function ScheduleCreatePage() { + const navigate = useNavigate() + return ( +
    + } title={T.newTitle} subtitle={T.hint} actions={BACK} /> +
    + { + rememberScheduleNames([s]) + toast.ok(T.created(s.name)) + // 表单刚保存完、离开守卫还没来得及撤:这一跳带通行证 + navigate(`/schedules/${encodeURIComponent(s.id)}`, leavePass({ replace: true })) + }} + /> +
    +
    + ) +} + +type TabKey = 'settings' | 'history' + +/** + * 一个定时任务的详情(/schedules/:id):页头是名字、频率和启用开关、立即运行、删除;下面两个标签,设置(表单) + * 和触发历史(?tab=history)。切到触发历史时表单不卸载,改到一半的内容还在。 + * + * 两份状态分开记:schedule 是页头和摘要显示的、随时可以刷新的最新值;version 是表单保存时带的版本,只跟着自己的 + * 写操作(开关、保存)和明确的「重新载入」走。别人在这期间改过时表单保存就该 409,不能因为页头顺手刷新过就悄悄覆盖 + */ +export function ScheduleDetailPage({ id }: { id: string }) { + const navigate = useNavigate() + const [params, setParams] = useSearchParams() + const tab: TabKey = params.get('tab') === 'history' ? 'history' : 'settings' + const [schedule, setSchedule] = useState(null) + const [version, setVersion] = useState(undefined) + const [error, setError] = useState(null) + const [resetKey, setResetKey] = useState(0) + const [historyKey, setHistoryKey] = useState(0) + // 两个标签都是第一次打开时才挂上,之后切走也不卸载:直接打开触发历史时不必先把表单和预览请求一遍 + const [historySeen, setHistorySeen] = useState(tab === 'history') + const [settingsSeen, setSettingsSeen] = useState(tab === 'settings') + const [busy, setBusy] = useState(null) + const now = useTicker(30_000) + const loaded = useRef(false) + + /** 读最新的定时任务,读到了返回 true。adopt:表单保存时也改用它的版本(首次打开、重新载入) */ + const load = useCallback(async (adopt: boolean): Promise => { + 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) => { + 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 = ( +
    + } title={T.notFound} body={T.notFoundHint} offline={false} + action={{T.back}} /> +
    + ) + } else if (isUnsupported(error)) { + content = } title={T.unsupported} body={T.unsupportedHint} offline={false} /> + } else if (!schedule) { + content = error + ? void load(true)} /> + :
    + } + + const actions = ( +
    + {schedule && ( + <> + void toggle(v)} /> + + void remove()} disabled={!!busy} data-schedule-delete="" + className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={} /> + + )} + {BACK} +
    + ) + + return ( +
    + } title={schedule?.name ?? T.title} subtitle={schedule?.description_text} + actions={actions} /> + {content ?? (schedule && ( + <> +
    +
    + {T.nextRun} + +
    +
    + {T.lastFire} + +
    +
    + +
    + {settingsSeen && ( + + )} + {historySeen && ( + + )} +
    + + ))} +
    + ) +} diff --git a/frontend/src/pages/schedules/ScheduleEditor.tsx b/frontend/src/pages/schedules/ScheduleEditor.tsx new file mode 100644 index 00000000..bbe8b216 --- /dev/null +++ b/frontend/src/pages/schedules/ScheduleEditor.tsx @@ -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) + const [initial, setInitial] = useState(() => formOf(schedule)) + const [form, setForm] = useState(initial) + const [touched, setTouched] = useState>>({}) + /** 新建时没动过运行类别:选工作流时按它发没发布替人选上 */ + 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) => setForm((f) => ({ ...f, ...patch })) + const setFreq = (patch: Partial) => setForm((f) => ({ ...f, freq: { ...f.freq, ...patch } })) + const touch = (k: FormField) => setTouched((t) => (t[k] ? t : { ...t, [k]: true })) + + const pickWorkflow = (w: Workflow) => 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 ( +
    { e.preventDefault(); void save() }} + data-schedule-form="" + > +
    +
    + + set({ name: e.target.value })} onBlur={() => touch('name')} data-field="name" /> + + touch('workflow')} /> +
    + + set({ timezone })} + freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} + tzError={previewSays('timezone') ?? serverError(['timezone'])} /> +
    + + + +
    + { runClassTouched.current = true; set({ runClass }) }} /> + set({ inputs })} /> +
    +

    {T.overlap}

    +

    {T.overlapText}

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

    {T.freq}

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

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

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

    {T.preview}

    + {preview.status === 'loading' && } +
    + {data && ( + <> + {data.description_text &&
    {data.description_text}
    } + {data.next?.length ? ( +
      + {data.next.map((at, i) => { + const d = new Date(at) + return ( +
    1. + {i + 1} + +
    2. + ) + })} +
    + ) :

    {T.previewNone}

    } + {tz &&

    {T.previewZone(tz)}

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

    {T.previewLoading}

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

    {T.previewIdle}

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

    {preview.message}

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

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

    + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// 运行类别 +// --------------------------------------------------------------------------- + +const RUN_CLASSES: readonly ScheduleRunClass[] = ['formal', 'exploratory'] + +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 ( +
    +

    {T.runClass}

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

    {T.formalVersion(workflow.published_version)}

    + )} +
    + ) +} + +// --------------------------------------------------------------------------- +// 输入 +// --------------------------------------------------------------------------- + +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() + +type FieldsState = + | { status: 'none' } + | { status: 'loading' } + | { status: 'error'; error: unknown; retry: () => void } + | { status: 'ok'; fields: InputField[]; version: number | null } + +/** + * 这个工作流的输入字段。正式运行到点按当时的发布版本发起,字段取已发布的那一版(和画布可能不同); + * 探索运行用工作流当前保存的内容,字段取它的入口节点 + */ +function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunClass): FieldsState { + const published = runClass === 'formal' && workflow?.published_version ? workflow.published_version : null + const key = workflow && published != null ? `${workflow.id}@${published}` : '' + const [fetched, setFetched] = useState<{ key: string; state: FieldsState } | null>(null) + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + if (!key || !workflow || published == null || versionFields.has(key)) return + let alive = true + setFetched({ key, state: { status: 'loading' } }) + api.workflows.version(workflow.id, published) + .then((v) => { + const list = v.input_fields?.length ? (v.input_fields as InputField[]) : graphFields(v.graph) + versionFields.set(key, list) + if (alive) setFetched({ key, state: { status: 'ok', fields: list, version: published } }) + }) + .catch((error) => { + if (alive) setFetched({ key, state: { status: 'error', error, retry: () => setAttempt((n) => n + 1) } }) + }) + return () => { alive = false } + }, [key, attempt]) + + if (!workflow) return { status: 'none' } + if (published == null) return { status: 'ok', fields: graphFields(workflow.graph), version: null } + const hit = versionFields.get(key) + if (hit) return { status: 'ok', fields: hit, version: published } + return fetched?.key === key ? fetched.state : { status: 'loading' } +} + +/** 输入模板里能用的变量:服务端给,各处共用一份;取不到时为空表,变量按钮照样在,只是说没有可用的 */ +let variablesOnce: Promise | null = null +function useVariables(): ScheduleVariable[] | null { + const [vars, setVars] = useState(null) + useEffect(() => { + let alive = true + variablesOnce ??= api.schedules.variables().then((v) => (Array.isArray(v) ? v : []), (e) => { + variablesOnce = null + throw e + }) + variablesOnce.then((v) => { if (alive) setVars(v) }, () => { if (alive) setVars([]) }) + return () => { alive = false } + }, []) + return vars +} + +const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v)) + +function InputsSection({ workflow, fields, inputs, variables, preview, error, onChange }: { + workflow: Workflow | undefined + fields: FieldsState + inputs: Record + variables: ScheduleVariable[] | null + preview: PreviewState + error?: string + onChange: (inputs: Record) => void +}) { + const refs = useRef>({}) + const [open, setOpen] = useState(null) + const rendered = preview.status === 'ok' ? preview.data.inputs ?? {} : null + const names = fields.status === 'ok' ? new Set(fields.fields.map((f) => f.name)) : null + const stale = names ? Object.keys(inputs).filter((k) => !names.has(k)) : [] + + const setValue = (name: string, value: string) => onChange({ ...inputs, [name]: value }) + /** 变量插在光标处(选中了文字就替换它),插完光标落在变量后面 */ + const insert = (name: string, token: string) => { + const el = refs.current[name] + const cur = inputs[name] ?? '' + const start = el?.selectionStart ?? cur.length + const end = el?.selectionEnd ?? cur.length + setValue(name, cur.slice(0, start) + token + cur.slice(end)) + requestAnimationFrame(() => { + el?.focus() + el?.setSelectionRange(start + token.length, start + token.length) + }) + } + + let body + if (fields.status === 'none') body =

    {T.inputsPick}

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

    {T.inputsNone}

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

    {T.inputs}

    +

    {T.inputsHint}

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

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

    + )} +
    + {body} + {error &&
    {error}
    } +
    + ) +} diff --git a/frontend/src/pages/schedules/SchedulesPage.tsx b/frontend/src/pages/schedules/SchedulesPage.tsx index cccc3664..d1ad3eff 100644 --- a/frontend/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/src/pages/schedules/SchedulesPage.tsx @@ -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 + if (id === 'new') return + return +} + +/** 列表每 30 秒原地刷新一次(页面不可见时不刷):到点触发之后,下次运行和上次触发要跟着变 */ +const LIST_REFRESH_MS = 30_000 + +function ScheduleListPage() { + const navigate = useNavigate() + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + /** 每行正在做的事:开关、立即运行、删除各自禁用,不让同一行连点出两个请求 */ + const [busy, setBusy] = useState>({}) + const now = useTicker(30_000) + const seq = useRef(0) + const hasRows = useRef(false) + + const 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) => { + 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 = ( + + {T.create} + + ) + + let body + if (unsupported) { + body = ( +
    + } title={T.unsupported} body={T.unsupportedHint} offline={false} /> +
    + ) + } else if (rows === null) { + body = error ? void load()} /> : + } else if (!rows.length) { + body = ( +
    + } title={T.emptyTitle} body={T.emptyBody} action={createButton} /> +
    + ) + } else { + body = ( +
      + {rows.map((s) => ( + void toggle(s, v)} onRun={() => void run(s)} onDelete={() => void remove(s)} /> + ))} +
    + ) + } + return ( -
    - } title={SCHEDULE_TEXT.title} subtitle={SCHEDULE_TEXT.hint} /> +
    + } title={T.title} subtitle={T.hint} actions={unsupported ? null : createButton} />
    - {/* 功能没做出来,和连没连上服务端无关:不走空态的离线说法 */} - } title={SCHEDULE_TEXT.pending} - body={SCHEDULE_TEXT.pendingHint} offline={false} /> +
    {body}
    ) } + +/** + * 一行:名字(链到详情)、工作流和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。 + * 窄屏上右边的按钮整组折到下一行,不挤名字 + */ +function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: { + s: Schedule; now: number; busy?: 'toggle' | 'run' | 'delete' + onToggle: (enabled: boolean) => void; onRun: () => void; onDelete: () => void +}) { + return ( +
  • +
    +
    +
    + + {s.name} + + + {!s.enabled && {T.pausedChip}} +
    +
    + {s.workflow_name} · {s.description_text} +
    +
    +
    + + + + {T.edit} + + } /> +
    +
    +
    +
    + {T.nextRun} + +
    +
    + {T.lastFire} + +
    +
    +
  • + ) +} diff --git a/frontend/src/pages/schedules/WebhookFields.tsx b/frontend/src/pages/schedules/WebhookFields.tsx new file mode 100644 index 00000000..e78a93a3 --- /dev/null +++ b/frontend/src/pages/schedules/WebhookFields.tsx @@ -0,0 +1,272 @@ +import { useEffect, useState } from 'react' +import { Send } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../../api/client' +import { Field, Notice, Spinner, isComposing, toast, useRadioGroup } from '../../components/ui' +import { errorMessage } from '../../lib/errors' +import { SCHEDULE_TEXT as T } from '../../lib/terms' +import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../types' +import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, type FormField, type WebhookForm } from './model' + +/** + * Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行链接用的服务访问地址。 + * + * 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」, + * 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置, + * 页面上改了还没保存时不让测,免得测的不是眼前这份 + */ +export function WebhookFields({ value, onChange, saved, problems, scheduleId, unsaved }: { + value: WebhookForm + onChange: (next: WebhookForm) => void + /** 已保存的 Webhook;新建、原来没有时为 null */ + saved: ScheduleWebhook | null + problems: Partial> + /** 已保存的定时任务 id;新建时为 null,不能测试 */ + scheduleId: string | null + /** Webhook 这一块有没保存的改动 */ + unsaved: boolean +}) { + const w = value + const set = (patch: Partial) => onChange({ ...w, ...patch }) + const radio = useRadioGroup(WEBHOOK_FORMATS, w.format, (format) => set({ format })) + const [urlTouched, setUrlTouched] = useState(false) + const [test, setTest] = useState<{ busy: boolean; result?: ScheduleWebhookTest; error?: unknown } | null>(null) + const askUrl = !saved || w.replaceUrl + const urlError = askUrl && (urlTouched || w.url.trim()) ? problems.url : undefined + const testBlocked = !scheduleId || !saved ? T.testNeedSave : unsaved ? T.testDirty : null + + // 设置一改,上一次的测试结果就不代表眼前这份了 + useEffect(() => { setTest((t) => (t?.busy ? t : null)) }, [unsaved]) + + const sendTest = async () => { + if (!scheduleId || testBlocked) return + setTest({ busy: true }) + try { + setTest({ busy: false, result: await api.schedules.testWebhook(scheduleId) }) + } catch (e) { + setTest({ busy: false, error: e }) + } + } + + return ( +
    +

    {T.webhook}

    + + + {!w.on && saved && ( + + {T.webhookRemoving} + + + )} + + {w.on && ( +
    +
    +
    {T.webhookFormat}
    +
    + {WEBHOOK_FORMATS.map((f) => ( + + ))} +
    +
    + + {askUrl ? ( + +
    + set({ url: e.target.value })} onBlur={() => setUrlTouched(true)} data-webhook-url="" /> + {saved && ( + + )} +
    +
    + ) : ( +
    +
    {T.urlSaved}
    +
    + + {saved?.url_hint} + + + +
    +
    {T.urlSavedHint}
    +
    + )} + + + +
    + {T.events} +
    + {SCHEDULE_EVENTS.map((ev) => ( + + ))} +
    + {problems.events &&
    {problems.events}
    } +
    + +
    + + {testBlocked && {testBlocked}} +
    + {test && !test.busy && ( + + {test.result + ? (test.result.ok ? T.testOk : T.testFailed)(test.result.http_status, test.result.message) + : T.testFailed(null, errorMessage(test.error))} + + )} + + +
    + )} +
    + ) +} + +const toggled = (events: ScheduleEvent[], ev: ScheduleEvent, on: boolean): ScheduleEvent[] => + on ? [...events.filter((x) => x !== ev), ev] : events.filter((x) => x !== ev) + +/** 签名密钥:没设置过时是一个密码框;设置过时只说「已设置」,可以替换或清除 */ +function SecretField({ value: w, saved, onChange, error }: { + value: WebhookForm; saved: ScheduleWebhook | null; onChange: (patch: Partial) => void; error?: string +}) { + const hasSecret = !!saved?.has_secret + if (hasSecret && w.secretMode === 'keep') { + return ( +
    +
    {T.secret}
    +
    + {T.secretSet} + + +
    +
    + ) + } + if (hasSecret && w.secretMode === 'clear') { + return ( +
    +
    {T.secret}
    +
    + {T.secretClearing} + +
    +
    + ) + } + return ( + +
    + onChange({ secret: e.target.value })} data-webhook-secret="" /> + {hasSecret && ( + + )} +
    +
    + ) +} + +/** + * 通知里运行链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口, + * 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉 + */ +function LinkBase() { + const [value, setValue] = useState(undefined) + const [failed, setFailed] = useState(false) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + let alive = true + api.settings.get() + .then((s) => { if (alive) setValue(typeof s?.notify?.public_base_url === 'string' ? s.notify.public_base_url : null) }) + .catch(() => { if (alive) setFailed(true) }) + return () => { alive = false } + }, []) + + const cleaned = draft.trim().replace(/\/+$/, '') + const invalid = !!cleaned && !isHttpUrl(cleaned) + const save = async () => { + if (invalid || busy) return + setBusy(true) + try { + const current = await api.settings.get() + const notify = { ...(current?.notify ?? {}), public_base_url: cleaned || null } + const out = await api.settings.put({ notify }) + setValue(typeof out?.notify?.public_base_url === 'string' ? out.notify.public_base_url : notify.public_base_url) + setFailed(false) + setEditing(false) + toast.ok(cleaned ? T.linkBaseSaved : T.linkBaseCleared) + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + + return ( +
    +
    + {T.linkBase} + + {failed ? T.linkBaseFailed : value === undefined ? : value ? T.linkBaseValue(value) : T.linkBaseUnset} + + {!editing && value !== undefined && ( + + )} +
    + {editing && ( +
    + +
    + setDraft(e.target.value)} + onKeyDown={(e) => { + // 在表单里:回车存这一项,不提交整张表单(输入法组字时的回车不算) + if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } + }} + data-link-base-input="" /> + + +
    +
    +
    + )} +
    + ) +} diff --git a/frontend/src/pages/schedules/WorkflowPicker.tsx b/frontend/src/pages/schedules/WorkflowPicker.tsx new file mode 100644 index 00000000..2b8ac3ae --- /dev/null +++ b/frontend/src/pages/schedules/WorkflowPicker.tsx @@ -0,0 +1,126 @@ +import { useId, useMemo, useState, type KeyboardEvent } from 'react' +import clsx from 'clsx' +import { isComposing } from '../../components/ui' +import { SCHEDULE_TEXT as T, WORKFLOW_STATUS_LABEL } from '../../lib/terms' +import type { Workflow } from '../../types' + +/** 工作流在下拉里的状态:「已发布 v3」「受管 v2」「草稿」 */ +export function workflowStatusText(w: Workflow): string { + if (!w.published_version || !w.status || w.status === 'draft') return T.workflowDraft + return T.workflowVersion(WORKFLOW_STATUS_LABEL[w.status], w.published_version) +} + +/** + * 可搜索的工作流选择(combobox + listbox)。聚焦就列出全部,打字按名称和说明筛;↑↓ 移动、回车选中、Esc 收起。 + * 没展开时输入框里写选中的那个的名字。选项按下时不抢焦点,点选不会先触发输入框失焦把列表收掉 + */ +export function WorkflowPicker({ id, value, workflows, onChange, onBlur, fallbackLabel, invalid, describedBy }: { + id: string + value: string + workflows: Workflow[] + onChange: (w: Workflow) => void + /** 选中的工作流不在列表里(列表还没取回来、或者已被删除)时,输入框里写的名字 */ + fallbackLabel?: string + onBlur?: () => void + invalid?: boolean + describedBy?: string +}) { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [active, setActive] = useState(0) + const listId = `${id}-list` + const optionId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + const selected = workflows.find((w) => w.id === value) + + const matches = useMemo(() => { + const q = query.trim().toLowerCase() + const sorted = [...workflows].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) + return q ? sorted.filter((w) => w.name.toLowerCase().includes(q) || (w.description ?? '').toLowerCase().includes(q)) : sorted + }, [workflows, query]) + + const pick = (w: Workflow) => { + onChange(w) + setOpen(false) + setQuery('') + } + const show = () => { + setOpen(true) + setQuery('') + setActive(Math.max(0, matches.findIndex((w) => w.id === value))) + } + const onKeyDown = (e: KeyboardEvent) => { + if (isComposing(e)) return + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + if (!open) { show(); return } + const step = e.key === 'ArrowDown' ? 1 : -1 + setActive((i) => Math.min(Math.max(0, i + step), Math.max(0, matches.length - 1))) + } else if (e.key === 'Enter') { + // 展开着时回车是选中,不是提交表单 + if (open && matches[active]) { + e.preventDefault() + pick(matches[active]) + } + } else if (e.key === 'Escape' && open) { + // 只收起列表,不让外层(弹窗、页面)跟着处理 Esc + e.preventDefault() + e.stopPropagation() + setOpen(false) + setQuery('') + } + } + + return ( +
    + { if (!open) show() }} + onChange={(e) => { + setQuery(e.target.value) + setOpen(true) + setActive(0) + }} + onBlur={() => { + setOpen(false) + setQuery('') + onBlur?.() + }} + onKeyDown={onKeyDown} + /> + {open && ( +
      + {matches.length ? matches.map((w, i) => ( +
    • e.preventDefault()} + onMouseEnter={() => setActive(i)} + onClick={() => pick(w)} + className={clsx('flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-xs', i === active && 'bg-hover')} + > + {w.name} + {workflowStatusText(w)} +
    • + )) :
    • {T.workflowNone}
    • } +
    + )} +
    + ) +} diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx new file mode 100644 index 00000000..ba7c2ecf --- /dev/null +++ b/frontend/src/pages/schedules/parts.tsx @@ -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 ( + + ) +} + +// ------------------------------------------------------------------------- +// 时间 +// ------------------------------------------------------------------------- + +/** + * 按定时任务的时区写一个时刻;relative 时前面加「3 小时后」。悬停写完整时间和时区,本机时区不同时另给本机时间: + * 只写一个钟点,人在别的时区打开会把它当本地时间 + */ +export function ZonedTime({ date, tz, now, relative = false, className }: { + date: Date; tz: string; now: number; relative?: boolean; className?: string +}) { + const abs = formatZoned(date, tz, 'short', new Date(now)) + const title = [T.zoneTitle(formatZoned(date, tz, 'full'), tz), + tz !== browserTimezone() ? T.localTitle(formatDateTime(date)) : null].filter(Boolean).join('\n') + const rel = relative ? formatUntil(date, now) ?? T.nextOverdue : null + return ( + + ) +} + +/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */ +export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) { + if (!schedule.enabled) return {T.nextPaused} + const d = parseServerTime(schedule.next_run_at) + if (!d) return {T.nextNone} + return +} + +// ------------------------------------------------------------------------- +// 触发 +// ------------------------------------------------------------------------- + +/** 触发结局的颜色:发起了是安静的完成色,跳过是琥珀,发起失败是红 */ +const FIRE_TONE: Record = { + started: 'var(--st-done)', + skipped_overlap: 'var(--st-waiting)', + skipped_misfire: 'var(--st-waiting)', + failed_to_start: 'var(--st-failed)', +} + +/** 触发结局的小标签。跳过、发起失败铺淡底,悬停写原因(没有原因写这种结局的一般说明) */ +export function FireStatusChip({ fire }: { fire: ScheduleFire }) { + const color = FIRE_TONE[fire.status] ?? 'var(--text-dim)' + return ( + + {T.fireStatus[fire.status] ?? fire.status} + + ) +} + +/** 触发类型:定时 / 手动 / 补发 */ +export function FireKindChip({ fire }: { fire: ScheduleFire }) { + return ( + + {T.fireKind[fire.kind] ?? fire.kind} + + ) +} + +/** 列表里的「上次触发」:发起了运行的链到那次运行、写运行现在的状态;跳过、发起失败的写结局 */ +export function LastFire({ fire, tz, now }: { fire: ScheduleFire | null; tz: string; now: number }) { + if (!fire) return {T.neverFired} + const d = parseServerTime(fire.fire_at) + const when = d ? : null + if (fire.run_id) { + return ( + + {fire.run_status ? : } + {when} + + ) + } + return ( + + + {when} + + ) +} + +// ------------------------------------------------------------------------- +// 列表行和详情页头共用的操作 +// ------------------------------------------------------------------------- + +/** 去掉服务端原话末尾的句号,后面好接自己的话 */ +const clause = (text: string) => text.replace(/[。.!!]+$/, '') + +/** + * 启用 / 暂停:带读到的版本 PATCH,成功交回新的定时任务。版本不符(409)时说服务端的原话、返回 'conflict', + * 由调用方重新载入;别的错误弹提示、返回 null + */ +export async function setEnabled(s: Schedule, enabled: boolean): Promise { + try { + const next = await api.schedules.update(s.id, { enabled, version: s.version }) + toast.ok(enabled ? T.enabledOk(s.name) : T.pausedOk(s.name)) + return next + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + toast.warn(`${clause(e.message)}。${T.conflictReloaded}`) + return 'conflict' + } + toast.error(e) + return null + } +} + +/** + * 立即运行:发起了就给「查看运行」直达,跳过、被拒照实说原因。返回这次触发(出错时 null), + * 调用方拿它更新「上次触发」和触发历史 + */ +export async function runNow(s: Schedule, navigate: (to: string) => void): Promise { + try { + const r = await api.schedules.run(s.id) + const runId = r.run_id ?? r.fire?.run_id ?? null + if (runId) { + toast.ok(T.runStarted, { action: { label: T.openRun, onClick: () => navigate(`/runs/${encodeURIComponent(runId)}`) } }) + } else if (r.fire?.status === 'skipped_overlap' || r.fire?.status === 'skipped_misfire') { + toast.warn(r.fire.status === 'skipped_overlap' ? T.runSkipped : T.fireStatus.skipped_misfire, + r.fire.reason ? { detail: r.fire.reason } : undefined) + } else { + toast.error(T.runFailed(r.fire?.reason ?? null)) + } + return r + } catch (e) { + toast.error(e) + return null + } +} + +/** 删除:先用站内对话框确认、写清后果。删掉了返回 true */ +export async function removeSchedule(s: Schedule): Promise { + const ok = await confirmDialog({ + title: T.deleteTitle(s.name), consequences: T.deleteConsequences, confirmLabel: T.deleteConfirm, danger: true, + }) + if (!ok) return false + try { + await api.schedules.remove(s.id) + rememberScheduleNames([], [s.id]) + toast.ok(T.deleted(s.name)) + return true + } catch (e) { + toast.error(e) + return false + } +} + +/** 老服务端没有定时任务接口:404 且没有机读码(有码的 404 是服务端自己说的「不存在」) */ +export const isUnsupported = (e: unknown) => e instanceof ApiError && e.status === 404 && !e.code + && /^not found$/i.test(String(e.detail ?? '')) + +// ------------------------------------------------------------------------- +// 记录页的「定时」标记 +// ------------------------------------------------------------------------- + +/** + * 定时任务的名字,给记录页的「定时」标记写悬停提示。模块里记一份:定时任务页读过列表就顺手记下,记录页遇到 + * 不认识的 id 再取一次列表(一分钟内最多一次)。取不到(老服务端、暂时连不上)不要紧,标记照样显示,只是不写名字 + */ +const names = new Map() +let fetchedAt = 0 +let inflight: Promise | null = null +let namesVersion = 0 +const nameListeners = new Set<() => void>() +const NAMES_RETRY_MS = 60_000 + +const emitNames = () => { + namesVersion++ + nameListeners.forEach((l) => l()) +} + +/** 记下读到的定时任务名字(gone 里的是刚删掉的) */ +export function rememberScheduleNames(items: Pick[], gone: string[] = []) { + for (const s of items) names.set(s.id, s.name) + for (const id of gone) names.delete(id) + emitNames() +} + +function ensureNames(ids: string[]) { + if (inflight || !ids.some((id) => !names.has(id)) || Date.now() - fetchedAt < NAMES_RETRY_MS) return + inflight = api.schedules.list({ timeoutMs: 10_000 }) + .then((r) => { for (const s of r.items ?? []) names.set(s.id, s.name) }) + .catch(() => { /* 取不到就不写名字 */ }) + .finally(() => { + inflight = null + fetchedAt = Date.now() + emitNames() + }) +} + +/** 这些定时任务的名字(id → 名字)。没记过的去取一次,取回来后重渲染 */ +export function useScheduleNames(ids: (string | null | undefined)[]): ReadonlyMap { + useSyncExternalStore( + (l) => { nameListeners.add(l); return () => { nameListeners.delete(l) } }, + () => namesVersion, + ) + const key = [...new Set(ids.filter((x): x is string => !!x))].sort().join(',') + useEffect(() => { if (key) ensureNames(key.split(',')) }, [key]) + return names +} + +/** + * 运行名字旁的「定时」标记:悬停写是哪个定时任务,点一下去那个定时任务。记录页的整行是打开运行的按钮, + * 这里拦住冒泡,只去定时任务、不打开运行 + */ +export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: string | null }) { + const navigate = useNavigate() + const go = (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + navigate(`/schedules/${encodeURIComponent(scheduleId)}`) + } + return ( + + {T.runTag} + + ) +}