import { zodResolver } from '@hookform/resolvers/zod' import { useQuery } from '@tanstack/react-query' import { Button, Checkbox, FormField, Input, Modal, Notice, PasswordInput, Select, Text, Textarea, TextButton, } from '@stash/ui' import { useEffect, useState, type ReactNode } from 'react' import { Controller, type FieldPath, type UseFormRegister, useFieldArray, useForm, } from 'react-hook-form' import { ApiRequestError, previewCron, probePythonVenv, type Task, type VenvProbeResponse, } from '../api/client' import { DirectoryPickerDialog } from './DirectoryPickerDialog' import { createEmptyTaskFormValues, mapServerFieldToFormField, taskFormSchema, taskToFormValues, type KeyValueFormValue, type TaskFormValues, } from '../lib/taskForm' import { useDebouncedValue } from '../lib/useDebouncedValue' interface TaskEditorModalProps { open: boolean task: Task | null saving: boolean onClose: () => void onSubmit: (values: TaskFormValues) => Promise } const ACTION_TYPE_OPTIONS = [ { value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' }, { value: 'http', label: '发起 HTTP 请求' }, { value: 'process', label: '管理常驻/独立二进制' }, ] const HTTP_METHOD_OPTIONS = [ { value: 'GET', label: 'GET' }, { value: 'POST', label: 'POST' }, ] const TIMEZONE_SUGGESTIONS = [ 'Asia/Shanghai', 'Asia/Hong_Kong', 'UTC', 'Europe/London', 'America/New_York', ] const CRON_PREVIEW_COUNTS = [3, 5, 8, 10] /** * 任务编辑器。 * * 外壳是组件库的 `Modal`(原生 dialog:焦点陷阱、Esc、背景 inert), * 字段排版用 `FormField`,控件用 `Input` / `Select` / `Textarea` / `Checkbox`, * 这里只保留校验(react-hook-form + zod)与业务联动。 */ export function TaskEditorModal({ open, task, saving, onClose, onSubmit, }: TaskEditorModalProps) { const [submitError, setSubmitError] = useState(null) const [cronPreviewCount, setCronPreviewCount] = useState(5) const [pickerOpen, setPickerOpen] = useState(false) const { register, control, handleSubmit, reset, watch, setError, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(taskFormSchema), defaultValues: createEmptyTaskFormValues(), mode: 'onBlur', }) const actionType = watch('actionType') const cronValue = watch('cron') const timezoneValue = watch('timezone') const scriptPreview = watch('script') const debouncedCron = useDebouncedValue(cronValue, 300) const debouncedTimezone = useDebouncedValue(timezoneValue, 300) const debouncedWorkingDir = useDebouncedValue( scriptPreview.working_dir ?? '', 350, ) const debouncedInterpreter = useDebouncedValue( scriptPreview.interpreter ?? '', 350, ) const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350) 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 venvProbe = useQuery({ queryKey: [ 'python-venv', debouncedWorkingDir, debouncedInterpreter, debouncedVenv, ], queryFn: ({ signal }) => probePythonVenv( { working_dir: debouncedWorkingDir, interpreter: debouncedInterpreter, venv: debouncedVenv.trim() || null, }, signal, ), enabled: open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()), retry: false, staleTime: 15_000, }) const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null const previewVirtualEnv = scriptPreview.venv.trim() || linkedVenv?.venv_path || '' const previewInterpreter = linkedVenv?.display_path ?? null 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) setPickerOpen(false) }, [open, reset, task]) // 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。 // 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。 useEffect(() => { if (!open) { return } document.getElementById(task ? 'task-name' : 'task-id')?.focus() }, [open, task]) const requestClose = () => { if (!saving) { onClose() } } 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, { type: 'server', message: error.message, }) return } } setSubmitError( error instanceof Error ? error.message : '保存任务时发生未知错误', ) } }) return ( } >
{submitError ? ( {submitError} ) : null}
预览未来运行时间
{TIMEZONE_SUGGESTIONS.map((zone) => ( (
运行参数 每行一个参数,不经过 Shell 拼接
{scriptArgs.fields.map((field, index) => (
scriptArgs.remove(index)} > 删除
))} {scriptArgs.fields.length === 0 ? ( 暂无运行参数 ) : null} {errors.script?.args?.message ? ( {errors.script.args.message} ) : null}
scriptEnv.append({ key: '', value: '' })} onRemove={scriptEnv.remove} />
调用预览 (Runtime Preview) Windows 原生环境
{previewVirtualEnv ? (
set VIRTUAL_ENV={previewVirtualEnv}
) : null} {scriptPreview.env .filter((item) => item.key.trim()) .map((item, index) => (
set {item.key.trim()}= {isSensitiveName(item.key) ? '[REDACTED]' : item.value || '""'}
))}
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '} {scriptPreview.script.trim() || 'main.py'} {formatArgumentPreview(scriptPreview.args)}
) : null} {actionType === 'http' ? (
(