feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+623
View File
@@ -0,0 +1,623 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
type FieldPath,
type UseFormRegister,
useFieldArray,
useForm,
} from 'react-hook-form'
import { ApiRequestError, previewCron, type Task } from '../api/client'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
taskFormSchema,
taskToFormValues,
type TaskFormValues,
} from '../lib/taskForm'
import { useFocusTrap } from '../lib/useFocusTrap'
import { useDebouncedValue } from '../lib/useDebouncedValue'
interface TaskEditorModalProps {
open: boolean
task: Task | null
saving: boolean
onClose: () => void
onSubmit: (values: TaskFormValues) => Promise<void>
}
export function TaskEditorModal({
open,
task,
saving,
onClose,
onSubmit,
}: TaskEditorModalProps) {
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
const {
register,
control,
handleSubmit,
reset,
watch,
setError,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: createEmptyTaskFormValues(),
mode: 'onBlur',
})
const actionType = watch('actionType')
const cronValue = watch('cron')
const timezoneValue = watch('timezone')
const debouncedCron = useDebouncedValue(cronValue, 300)
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
const cronPreview = useQuery({
queryKey: [
'cron-preview',
debouncedCron,
debouncedTimezone,
cronPreviewCount,
],
queryFn: ({ signal }) =>
previewCron(
{
expression: debouncedCron,
timezone: debouncedTimezone,
count: cronPreviewCount,
},
signal,
),
enabled:
open &&
Boolean(debouncedCron.trim()) &&
Boolean(debouncedTimezone.trim()),
retry: false,
})
const scriptArgs = useFieldArray({ control, name: 'script.args' })
const scriptEnv = useFieldArray({ control, name: 'script.env' })
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
const processArgs = useFieldArray({ control, name: 'process.args' })
const processEnv = useFieldArray({ control, name: 'process.env' })
useEffect(() => {
if (!open) {
return
}
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
setCronPreviewCount(5)
setSubmitError(null)
}, [open, reset, task])
useEffect(() => {
if (!open) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !saving) {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose, open, saving])
if (!open) {
return null
}
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
await onSubmit(values)
} catch (error) {
if (error instanceof ApiRequestError && error.field) {
const formField = mapServerFieldToFormField(error.field, values.actionType)
if (formField) {
setError(formField as FieldPath<TaskFormValues>, {
type: 'server',
message: error.message,
})
return
}
}
setSubmitError(
error instanceof Error ? error.message : '保存任务时发生未知错误',
)
}
})
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !saving) {
onClose()
}
}}
>
<section
ref={dialogRef}
className="modal-card task-editor"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="task-editor-title"
>
<header className="modal-header">
<div>
<h2 id="task-editor-title">
{task ? `编辑任务 [${task.id}]` : '新建定时任务'}
</h2>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
disabled={saving}
aria-label="关闭任务编辑器"
>
×
</button>
</header>
<form className="modal-body" onSubmit={submit} noValidate>
{submitError ? (
<div className="inline-alert inline-alert-error" role="alert">
{submitError}
</div>
) : null}
<div className="form-grid">
<Field label="任务标识" error={errors.id?.message}>
<input
{...register('id')}
className="field-input mono"
placeholder="例如:sync_data"
disabled={Boolean(task)}
data-dialog-autofocus
aria-invalid={Boolean(errors.id)}
/>
</Field>
<Field label="任务名称" error={errors.name?.message}>
<input
{...register('name')}
className="field-input"
placeholder="业务可读名称"
aria-invalid={Boolean(errors.name)}
/>
</Field>
</div>
<div className="form-grid">
<div className="field">
<div className="cron-header-row">
<span className="field-label">Cron 表达式</span>
<div className="cron-n-selector">
<span>预览未来</span>
<select
value={cronPreviewCount}
onChange={(event) =>
setCronPreviewCount(Number(event.target.value))
}
aria-label="Cron 预览次数"
>
<option value="3">3 次</option>
<option value="5">5 次</option>
<option value="8">8 次</option>
<option value="10">10 次</option>
</select>
</div>
</div>
<input
{...register('cron')}
className="field-input mono"
placeholder="0 0 2 * * *"
aria-invalid={Boolean(errors.cron)}
/>
<div className="cron-preview-box" aria-live="polite">
{cronPreview.isFetching ? (
<span className="cron-preview-message">正在计算...</span>
) : cronPreview.isError ? (
<span className="cron-preview-error">
{cronPreview.error.message}
</span>
) : cronPreview.data?.runs.length ? (
cronPreview.data.runs.map((run, index) => (
<div className="cron-run-item" key={run}>
<span className="cron-run-index">#{index + 1}</span>
<span className="cron-run-time">{run}</span>
</div>
))
) : (
<span className="cron-preview-message">
等待输入有效 Cron 表达式...
</span>
)}
</div>
{errors.cron?.message ? (
<span className="field-error">{errors.cron.message}</span>
) : (
<span className="field-hint">
支持 5/6 位字段及 @daily、@hourly
</span>
)}
</div>
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}>
<input
{...register('timeout_secs', { valueAsNumber: true })}
type="number"
min="1"
max="86400"
className="field-input mono"
aria-invalid={Boolean(errors.timeout_secs)}
/>
<span className="field-hint">
超时后将被 Windows Job Object 强制终止
</span>
</Field>
</div>
<div className="form-grid">
<Field label="IANA 时区" error={errors.timezone?.message}>
<input
{...register('timezone')}
className="field-input"
list="timezone-options"
placeholder="Asia/Shanghai"
aria-invalid={Boolean(errors.timezone)}
/>
<datalist id="timezone-options">
<option value="Asia/Shanghai" />
<option value="Asia/Hong_Kong" />
<option value="UTC" />
<option value="Europe/London" />
<option value="America/New_York" />
</datalist>
</Field>
<Field label="任务类型">
<select {...register('actionType')} className="field-input">
<option value="script">执行脚本</option>
<option value="http">HTTP 请求</option>
<option value="process">二进制进程</option>
</select>
</Field>
</div>
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
{actionType === 'script' ? (
<fieldset className="action-panel">
<legend>脚本配置</legend>
<div className="form-grid">
<Field
label="工作目录"
error={errors.script?.working_dir?.message}
>
<input
{...register('script.working_dir')}
className="field-input mono"
placeholder="D:\Projects\sync"
/>
</Field>
<Field
label="解释器"
error={errors.script?.interpreter?.message}
>
<input
{...register('script.interpreter')}
className="field-input mono"
placeholder="python.exe"
/>
</Field>
</div>
<Field
label="脚本文件或相对路径"
error={errors.script?.script?.message}
>
<input
{...register('script.script')}
className="field-input mono"
placeholder="main.py 或 scripts\sync.py"
/>
</Field>
<div className="collection-header">
<div>
<strong>运行参数</strong>
<small>每行一个参数,不经过 Shell 拼接</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => scriptArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{scriptArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`script.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--mode"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => scriptArgs.remove(index)}
>
删除
</button>
</div>
))}
{scriptArgs.fields.length === 0 ? (
<p className="empty-inline">暂无运行参数</p>
) : null}
{errors.script?.args?.message ? (
<span className="field-error">
{errors.script.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={scriptEnv.fields}
registerKey={register}
name="script.env"
error={errors.script?.env?.message}
onAdd={() => scriptEnv.append({ key: '', value: '' })}
onRemove={scriptEnv.remove}
/>
</fieldset>
) : null}
{actionType === 'http' ? (
<fieldset className="action-panel">
<legend>HTTP 配置</legend>
<div className="form-grid">
<Field label="请求 URL" error={errors.http?.url?.message}>
<input
{...register('http.url')}
className="field-input mono"
placeholder="https://api.example.com/health"
/>
</Field>
<Field label="请求方式">
<select {...register('http.method')} className="field-input">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</Field>
</div>
<div className="form-grid">
<Field
label="期望状态码"
hint="使用逗号分隔,例如:200, 204"
error={errors.http?.expected_status?.message}
>
<input
{...register('http.expected_status')}
className="field-input mono"
placeholder="200"
/>
</Field>
<Field label="JSON Body" error={errors.http?.body?.message}>
<textarea
{...register('http.body')}
className="field-input field-textarea mono"
rows={4}
placeholder='{"trigger":"manual"}'
/>
</Field>
</div>
<KeyValueEditor
title="请求 Header"
fields={httpHeaders.fields}
registerKey={register}
name="http.headers"
error={errors.http?.headers?.message}
onAdd={() => httpHeaders.append({ key: '', value: '' })}
onRemove={httpHeaders.remove}
/>
</fieldset>
) : null}
{actionType === 'process' ? (
<fieldset className="action-panel">
<legend>进程配置</legend>
<div className="form-grid">
<Field
label="可执行程序"
error={errors.process?.executable?.message}
>
<input
{...register('process.executable')}
className="field-input mono"
placeholder="C:\Tools\exporter.exe"
/>
</Field>
<Field label="工作目录">
<input
{...register('process.working_dir')}
className="field-input mono"
placeholder="可选"
/>
</Field>
</div>
<div className="collection-header">
<div>
<strong>启动参数</strong>
<small>每行一个参数</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => processArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{processArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`process.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--port"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => processArgs.remove(index)}
>
删除
</button>
</div>
))}
{processArgs.fields.length === 0 ? (
<p className="empty-inline">暂无启动参数</p>
) : null}
{errors.process?.args?.message ? (
<span className="field-error">
{errors.process.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={processEnv.fields}
registerKey={register}
name="process.env"
error={errors.process?.env?.message}
onAdd={() => processEnv.append({ key: '', value: '' })}
onRemove={processEnv.remove}
/>
</fieldset>
) : null}
<footer className="form-actions">
<button
type="button"
className="button button-secondary"
onClick={onClose}
disabled={saving}
>
取消
</button>
<button
type="submit"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</form>
</section>
</div>
)
}
interface FieldProps {
label: string
error?: string
hint?: string
children: ReactNode
}
function Field({ label, error, hint, children }: FieldProps) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
{error ? <span className="field-error">{error}</span> : null}
{!error && hint ? <span className="field-hint">{hint}</span> : null}
</label>
)
}
interface KeyValueEditorProps {
title: string
name: 'script.env' | 'http.headers' | 'process.env'
fields: Array<{ id: string; key: string; value: string }>
registerKey: UseFormRegister<TaskFormValues>
onAdd: () => void
onRemove: (index: number) => void
error?: string
}
function KeyValueEditor({
title,
name,
fields,
registerKey,
onAdd,
onRemove,
error,
}: KeyValueEditorProps) {
return (
<div className="key-value-editor">
<div className="collection-header">
<div>
<strong>{title}</strong>
<small>名称和值会原样提交给后端</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={onAdd}
>
+ 添加
</button>
</div>
<div className="collection-list">
{fields.map((field, index) => (
<div className="key-value-row" key={field.id}>
<input
{...registerKey(
`${name}.${index}.key` as FieldPath<TaskFormValues>,
)}
className="field-input mono"
placeholder="名称"
/>
<input
{...registerKey(
`${name}.${index}.value` as FieldPath<TaskFormValues>,
)}
type="password"
autoComplete="off"
className="field-input mono"
placeholder="值"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => onRemove(index)}
>
删除
</button>
</div>
))}
{fields.length === 0 ? <p className="empty-inline">暂无配置</p> : null}
{error ? <span className="field-error">{error}</span> : null}
</div>
</div>
)
}