import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Button, Checkbox, FormField, Icon, Input, Modal, Notice, PasswordInput, Select, Spinner, Text, Textarea, TextButton, toast, } from '@stash/ui' import { useEffect, useRef, useState, type ReactNode } from 'react' import { Controller, type FieldPath, type UseFormRegister, useFieldArray, useForm, } from 'react-hook-form' import { ApiRequestError, createPythonVenv, previewCron, probePythonVenv, type Task, type VenvProbeResponse, } from '../api/client' import { DirectoryPickerDialog } from './DirectoryPickerDialog' import { createEmptyTaskFormValues, mapServerFieldToFormField, SCRIPT_LANGUAGE_META, taskFormSchema, taskToFormValues, type KeyValueFormValue, type ScriptLanguage, 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: '执行脚本' }, { value: 'http', label: '发起 HTTP 请求' }, { value: 'process', label: '运行二进制程序' }, ] const SCRIPT_LANGUAGE_OPTIONS = ( Object.entries(SCRIPT_LANGUAGE_META) as Array< [ScriptLanguage, (typeof SCRIPT_LANGUAGE_META)[ScriptLanguage]] > ).map(([value, meta]) => ({ value, label: meta.label })) const HTTP_METHOD_OPTIONS = [ { value: 'GET', label: 'GET' }, { value: 'POST', label: 'POST' }, ] const TIMEZONE_OPTIONS = ['Asia/Shanghai'] 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 queryClient = useQueryClient() // 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖; // 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。 const autoVenvRef = useRef('') const { register, control, getValues, handleSubmit, reset, watch, setError, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(taskFormSchema), defaultValues: createEmptyTaskFormValues(), // 长表单在 blur 时插错误行会让下面所有控件瞬间位移,刚点下去的按钮 // 可能在 mouseup 前被挪走(下拉框会「点了没反应」)。改为提交时校验, // 提交后仍按 onChange 实时修正。 mode: 'onSubmit', }) const actionType = watch('actionType') const cronValue = watch('cron') const timezoneValue = watch('timezone') const scriptPreview = watch('script') const scriptLanguage = scriptPreview.language const scriptMeta = SCRIPT_LANGUAGE_META[scriptLanguage] const isPythonScript = scriptLanguage === 'python' // 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项, // 避免下拉显示成空值。 const timezoneOptions = Array.from( new Set(timezoneValue ? [...TIMEZONE_OPTIONS, timezoneValue] : TIMEZONE_OPTIONS), ).map((zone) => ({ value: zone, label: zone })) 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) // 切换工作目录时会同步清掉自动填入的 venv;如果实时值已经为空, // 探测必须立刻当作没有 venv,不能继续用防抖中的旧值, // 否则会先报“缺少 pyvenv.cfg”,再跳到“未检测到虚拟环境”。 const probeVenv = scriptPreview.venv.trim() ? debouncedVenv : '' 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 applyScriptLanguage = (language: ScriptLanguage) => { const meta = SCRIPT_LANGUAGE_META[language] const currentScript = getValues('script.script').trim() const knownDefaults = Object.values(SCRIPT_LANGUAGE_META).map( (item) => item.defaultScript, ) setValue('script.language', language, { shouldDirty: true, shouldValidate: true, }) setValue('script.interpreter', meta.defaultInterpreter, { shouldDirty: true, shouldValidate: true, }) if (!currentScript || knownDefaults.includes(currentScript)) { setValue('script.script', meta.defaultScript, { shouldDirty: true, shouldValidate: true, }) } if (language !== 'python') { setValue('script.venv', '', { shouldDirty: true, shouldValidate: true, }) autoVenvRef.current = '' } } const clearAutoVenv = () => { if ( autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current ) { setValue('script.venv', '', { shouldDirty: false }) autoVenvRef.current = '' } } const workingDirField = register('script.working_dir') const venvProbe = useQuery({ queryKey: [ 'python-venv', debouncedWorkingDir, debouncedInterpreter, probeVenv, scriptLanguage, ], queryFn: ({ signal }) => probePythonVenv( { working_dir: debouncedWorkingDir, interpreter: debouncedInterpreter.trim() || 'python.exe', venv: probeVenv.trim() || null, }, signal, ), enabled: open && actionType === 'script' && isPythonScript && Boolean(debouncedWorkingDir.trim()), retry: false, staleTime: 15_000, }) const createVenvMutation = useMutation({ mutationFn: (workingDir: string) => createPythonVenv({ working_dir: workingDir }), onSuccess: (probe, workingDir) => { toast.success('虚拟环境已创建') if (probe.venv_path) { const value = venvFieldValue(workingDir, probe.venv_path) autoVenvRef.current = value setValue('script.venv', value, { shouldDirty: true, shouldValidate: true, }) } void queryClient.invalidateQueries({ queryKey: ['python-venv'] }) }, onError: (error) => toast.error(error.message), }) const linkedVenv = isPythonScript && venvProbe.data?.status === 'linked' ? venvProbe.data : null const previewVirtualEnv = isPythonScript ? scriptPreview.venv.trim() || linkedVenv?.venv_path || '' : '' const previewInterpreter = isPythonScript ? (linkedVenv?.display_path ?? null) : 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) autoVenvRef.current = '' }, [open, reset, task]) // 换工作目录时清掉上一轮自动填入的值(手动填写的值不动)。 useEffect(() => { if (!open) { return } if ( autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current ) { setValue('script.venv', '', { shouldDirty: false }) autoVenvRef.current = '' } }, [debouncedWorkingDir, getValues, open, setValue]) // 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。 useEffect(() => { if (!open || !isPythonScript) { return } const probe = venvProbe.data if (!probe || probe.status !== 'linked' || !probe.venv_path) { return } // 工作目录还在防抖中时,旧查询结果不能回填到新目录。 if (debouncedWorkingDir !== scriptPreview.working_dir.trim()) { return } const current = getValues('script.venv').trim() // 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」 // 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。 if (debouncedVenv.trim() !== current) { return } if (current && current !== autoVenvRef.current) { return } if (current !== '' && current === autoVenvRef.current) { return } const next = venvFieldValue(debouncedWorkingDir, probe.venv_path) autoVenvRef.current = next setValue('script.venv', next, { shouldDirty: true, shouldValidate: true, }) }, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, scriptPreview.working_dir, setValue, venvProbe.data]) // 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 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}
未来运行时间 按当前 Cron 与时区实时计算
预览 )} /> ( applyScriptLanguage(value as ScriptLanguage) } /> )} />
{scriptMeta.hint}
) : null} {actionType === 'script' ? (
{ // 先清掉旧目录自动填入的 venv,再更新工作目录; // 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。 clearAutoVenv() void workingDirField.onChange(event) }} className="mono-input" placeholder="D:\Projects\sync" aria-invalid={Boolean(errors.script?.working_dir)} />
{isPythonScript ? ( <> createVenvMutation.mutate(scriptPreview.working_dir.trim()) } /> ) : null}
运行参数 每行一个参数,不经过 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 || '""'}
))}
{formatScriptCommandPreview( scriptPreview, previewInterpreter, scriptLanguage, )}
) : null} {actionType === 'http' ? (
(