feat: implement full-stack TaskPilot scheduler
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user