1213 lines
37 KiB
TypeScript
1213 lines
37 KiB
TypeScript
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<void>
|
||
}
|
||
|
||
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<string | null>(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<TaskFormValues>({
|
||
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<TaskFormValues>, {
|
||
type: 'server',
|
||
message: error.message,
|
||
})
|
||
return
|
||
}
|
||
}
|
||
setSubmitError(
|
||
error instanceof Error ? error.message : '保存任务时发生未知错误',
|
||
)
|
||
}
|
||
})
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={requestClose}
|
||
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
||
size="lg"
|
||
className="task-editor-modal"
|
||
closeOnOverlay={!saving}
|
||
footer={
|
||
<>
|
||
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||
取消
|
||
</Button>
|
||
<Button type="submit" form="task-editor-form" loading={saving}>
|
||
{task ? '保存修改' : '保存并热刷新'}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<form
|
||
id="task-editor-form"
|
||
className="task-editor-form"
|
||
onSubmit={submit}
|
||
noValidate
|
||
>
|
||
{submitError ? (
|
||
<Notice tone="error" title="保存失败">
|
||
{submitError}
|
||
</Notice>
|
||
) : null}
|
||
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="任务标识 (Task ID)"
|
||
htmlFor="task-id"
|
||
error={errors.id?.message}
|
||
>
|
||
<Input
|
||
id="task-id"
|
||
{...register('id')}
|
||
className="mono-input"
|
||
placeholder="例如:sync_data"
|
||
disabled={Boolean(task)}
|
||
aria-invalid={Boolean(errors.id)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="名称" htmlFor="task-name" error={errors.name?.message}>
|
||
<Input
|
||
id="task-name"
|
||
{...register('name')}
|
||
placeholder="业务可读名称"
|
||
aria-invalid={Boolean(errors.name)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="Cron 表达式"
|
||
htmlFor="task-cron"
|
||
error={errors.cron?.message}
|
||
hint={
|
||
errors.cron
|
||
? undefined
|
||
: '支持 5/6 位字段及 @daily、@hourly'
|
||
}
|
||
>
|
||
<Input
|
||
id="task-cron"
|
||
{...register('cron')}
|
||
className="mono-input"
|
||
placeholder="0 0 2 * * *"
|
||
aria-invalid={Boolean(errors.cron) || cronPreview.isError}
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField
|
||
label="超时时间 (秒)"
|
||
htmlFor="task-timeout"
|
||
error={errors.timeout_secs?.message}
|
||
hint={
|
||
errors.timeout_secs
|
||
? undefined
|
||
: '超时后将被 Windows Job Object 强制终止'
|
||
}
|
||
>
|
||
<Input
|
||
id="task-timeout"
|
||
type="number"
|
||
min={1}
|
||
max={86_400}
|
||
{...register('timeout_secs', { valueAsNumber: true })}
|
||
className="mono-input"
|
||
aria-invalid={Boolean(errors.timeout_secs)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
<section
|
||
className="cron-preview-panel"
|
||
aria-label="未来运行时间"
|
||
aria-live="polite"
|
||
>
|
||
<div className="cron-preview-head">
|
||
<div className="cron-preview-title">
|
||
<Text strong size="sm">
|
||
未来运行时间
|
||
</Text>
|
||
<Text size="xs" tone="muted">
|
||
按当前 Cron 与时区实时计算
|
||
</Text>
|
||
</div>
|
||
<div className="cron-preview-actions">
|
||
<Text size="xs" tone="muted">
|
||
预览
|
||
</Text>
|
||
<Select
|
||
className="cron-count-select"
|
||
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
||
value: String(count),
|
||
label: `${count} 次`,
|
||
}))}
|
||
value={String(cronPreviewCount)}
|
||
onChange={(value) => setCronPreviewCount(Number(value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{cronPreview.isFetching ? (
|
||
<div className="cron-preview-status">
|
||
<Spinner size="sm" label="正在计算" />
|
||
<Text size="sm" tone="sub">
|
||
正在计算...
|
||
</Text>
|
||
</div>
|
||
) : cronPreview.isError ? (
|
||
<div className="cron-preview-error">
|
||
<Icon name="warning_line" />
|
||
<span>
|
||
{cronPreview.error instanceof ApiRequestError
|
||
? cronPreview.error.message
|
||
: '暂时无法计算运行时间,请稍后重试'}
|
||
</span>
|
||
</div>
|
||
) : cronPreview.data?.runs.length ? (
|
||
<div className="cron-run-grid">
|
||
{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>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<Text size="sm" tone="sub">
|
||
等待输入有效 Cron 表达式...
|
||
</Text>
|
||
)}
|
||
</section>
|
||
|
||
<div className="form-grid">
|
||
<FormField label="IANA 时区" error={errors.timezone?.message}>
|
||
<Controller
|
||
control={control}
|
||
name="timezone"
|
||
render={({ field }) => (
|
||
<Select
|
||
className="form-select"
|
||
options={timezoneOptions}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
)}
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="任务类型 (Action Type)">
|
||
<Controller
|
||
control={control}
|
||
name="actionType"
|
||
render={({ field }) => (
|
||
<Select
|
||
className="form-select"
|
||
options={ACTION_TYPE_OPTIONS}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
{actionType === 'script' ? (
|
||
<div className="script-language-row">
|
||
<div className="script-language-field">
|
||
<FormField label="脚本语言">
|
||
<Controller
|
||
control={control}
|
||
name="script.language"
|
||
render={({ field }) => (
|
||
<Select
|
||
className="form-select"
|
||
options={SCRIPT_LANGUAGE_OPTIONS}
|
||
value={field.value}
|
||
onChange={(value) =>
|
||
applyScriptLanguage(value as ScriptLanguage)
|
||
}
|
||
/>
|
||
)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<Text className="script-language-hint" size="sm" tone="sub">
|
||
{scriptMeta.hint}
|
||
</Text>
|
||
</div>
|
||
) : null}
|
||
|
||
{actionType === 'script' ? (
|
||
<div className="dynamic-fields">
|
||
<FormField
|
||
label="项目工作目录 (Working Directory)"
|
||
htmlFor="task-working-dir"
|
||
error={errors.script?.working_dir?.message}
|
||
>
|
||
<div className="field-with-action">
|
||
<Input
|
||
id="task-working-dir"
|
||
{...workingDirField}
|
||
onChange={(event) => {
|
||
// 先清掉旧目录自动填入的 venv,再更新工作目录;
|
||
// 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。
|
||
clearAutoVenv()
|
||
void workingDirField.onChange(event)
|
||
}}
|
||
className="mono-input"
|
||
placeholder="D:\Projects\sync"
|
||
aria-invalid={Boolean(errors.script?.working_dir)}
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
// 不夺走输入框焦点:否则 blur 校验插入错误行会让表单瞬间下移,
|
||
// 点击可能落到移位后的其它元素上。
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => setPickerOpen(true)}
|
||
>
|
||
选择文件夹…
|
||
</Button>
|
||
</div>
|
||
</FormField>
|
||
|
||
{isPythonScript ? (
|
||
<>
|
||
<FormField
|
||
label="虚拟环境目录"
|
||
htmlFor="task-venv"
|
||
hint="自动探测项目里的 .venv;需要时也可以手动指定。Python 任务始终使用 .venv 里的 Python"
|
||
error={errors.script?.venv?.message}
|
||
>
|
||
<Input
|
||
id="task-venv"
|
||
{...register('script.venv')}
|
||
className="mono-input"
|
||
placeholder="留空自动探测,例如 .venv"
|
||
/>
|
||
</FormField>
|
||
|
||
<VenvDetectorBar
|
||
workingDir={scriptPreview.working_dir}
|
||
probe={venvProbe.data}
|
||
pending={venvProbe.isPending}
|
||
failed={venvProbe.isError}
|
||
creating={createVenvMutation.isPending}
|
||
onCreate={() =>
|
||
createVenvMutation.mutate(scriptPreview.working_dir.trim())
|
||
}
|
||
/>
|
||
</>
|
||
) : null}
|
||
|
||
<FormField
|
||
label={scriptMeta.scriptLabel}
|
||
htmlFor="task-script"
|
||
error={errors.script?.script?.message}
|
||
>
|
||
<Input
|
||
id="task-script"
|
||
{...register('script.script')}
|
||
className="mono-input"
|
||
placeholder={scriptMeta.scriptPlaceholder}
|
||
/>
|
||
</FormField>
|
||
|
||
<div className="collection-header">
|
||
<div>
|
||
<Text strong size="sm">
|
||
运行参数
|
||
</Text>
|
||
<Text size="xs" tone="muted">
|
||
每行一个参数,不经过 Shell 拼接
|
||
</Text>
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => scriptArgs.append({ key: '', value: '' })}
|
||
>
|
||
+ 添加参数
|
||
</Button>
|
||
</div>
|
||
<div className="collection-list">
|
||
{scriptArgs.fields.map((field, index) => (
|
||
<div className="argument-row" key={field.id}>
|
||
<Input
|
||
{...register(`script.args.${index}.key`)}
|
||
className="mono-input"
|
||
placeholder="参数名 (如 --mode 或 -v)"
|
||
/>
|
||
<Input
|
||
{...register(`script.args.${index}.value`)}
|
||
className="mono-input"
|
||
placeholder="参数值 (可选,无值可留空)"
|
||
/>
|
||
<TextButton
|
||
tone="danger"
|
||
onClick={() => scriptArgs.remove(index)}
|
||
>
|
||
删除
|
||
</TextButton>
|
||
</div>
|
||
))}
|
||
{scriptArgs.fields.length === 0 ? (
|
||
<Text size="sm" tone="muted">
|
||
暂无运行参数
|
||
</Text>
|
||
) : null}
|
||
{errors.script?.args?.message ? (
|
||
<span className="field-error">
|
||
{errors.script.args.message}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<KeyValueEditor
|
||
title="环境变量"
|
||
name="script.env"
|
||
fields={scriptEnv.fields}
|
||
registerField={register}
|
||
error={errors.script?.env?.message}
|
||
onAdd={() => scriptEnv.append({ key: '', value: '' })}
|
||
onRemove={scriptEnv.remove}
|
||
/>
|
||
|
||
<div className="cli-preview">
|
||
<div className="cli-preview-header">
|
||
<span>调用预览 (Runtime Preview)</span>
|
||
<span>Windows 原生环境</span>
|
||
</div>
|
||
<div className="cli-preview-body">
|
||
{previewVirtualEnv ? (
|
||
<div className="cli-line-env">
|
||
set VIRTUAL_ENV={previewVirtualEnv}
|
||
</div>
|
||
) : null}
|
||
{scriptPreview.env
|
||
.filter((item) => item.key.trim())
|
||
.map((item, index) => (
|
||
<div className="cli-line-env" key={`${item.key}-${index}`}>
|
||
set {item.key.trim()}=
|
||
{isSensitiveName(item.key)
|
||
? '[REDACTED]'
|
||
: item.value || '""'}
|
||
</div>
|
||
))}
|
||
<div className="cli-line-cmd">
|
||
{formatScriptCommandPreview(
|
||
scriptPreview,
|
||
previewInterpreter,
|
||
scriptLanguage,
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{actionType === 'http' ? (
|
||
<div className="dynamic-fields">
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="请求 URL"
|
||
htmlFor="task-http-url"
|
||
error={errors.http?.url?.message}
|
||
>
|
||
<Input
|
||
id="task-http-url"
|
||
{...register('http.url')}
|
||
className="mono-input"
|
||
placeholder="https://api.example.com/health"
|
||
/>
|
||
</FormField>
|
||
<FormField label="请求方式">
|
||
<Controller
|
||
control={control}
|
||
name="http.method"
|
||
render={({ field }) => (
|
||
<Select
|
||
className="form-select"
|
||
options={HTTP_METHOD_OPTIONS}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="期望状态码"
|
||
htmlFor="task-http-status"
|
||
hint="使用逗号分隔,例如:200, 204"
|
||
error={errors.http?.expected_status?.message}
|
||
>
|
||
<Input
|
||
id="task-http-status"
|
||
{...register('http.expected_status')}
|
||
className="mono-input"
|
||
placeholder="200"
|
||
/>
|
||
</FormField>
|
||
<FormField
|
||
label="JSON Body"
|
||
htmlFor="task-http-body"
|
||
error={errors.http?.body?.message}
|
||
>
|
||
<Textarea
|
||
id="task-http-body"
|
||
{...register('http.body')}
|
||
rows={4}
|
||
className="mono-input"
|
||
placeholder='{"trigger":"manual"}'
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<KeyValueEditor
|
||
title="请求 Header"
|
||
name="http.headers"
|
||
fields={httpHeaders.fields}
|
||
registerField={register}
|
||
error={errors.http?.headers?.message}
|
||
onAdd={() => httpHeaders.append({ key: '', value: '' })}
|
||
onRemove={httpHeaders.remove}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{actionType === 'process' ? (
|
||
<div className="dynamic-fields">
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="可执行程序"
|
||
htmlFor="task-process-exe"
|
||
error={errors.process?.executable?.message}
|
||
>
|
||
<Input
|
||
id="task-process-exe"
|
||
{...register('process.executable')}
|
||
className="mono-input"
|
||
placeholder="C:\Tools\exporter.exe"
|
||
/>
|
||
</FormField>
|
||
<FormField label="工作目录" htmlFor="task-process-dir">
|
||
<Input
|
||
id="task-process-dir"
|
||
{...register('process.working_dir')}
|
||
className="mono-input"
|
||
placeholder="可选"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<div className="collection-header">
|
||
<div>
|
||
<Text strong size="sm">
|
||
启动参数
|
||
</Text>
|
||
<Text size="xs" tone="muted">
|
||
每行一个参数
|
||
</Text>
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => processArgs.append({ key: '', value: '' })}
|
||
>
|
||
+ 添加参数
|
||
</Button>
|
||
</div>
|
||
<div className="collection-list">
|
||
{processArgs.fields.map((field, index) => (
|
||
<div className="argument-row" key={field.id}>
|
||
<Input
|
||
{...register(`process.args.${index}.key`)}
|
||
className="mono-input"
|
||
placeholder="参数名 (如 --port)"
|
||
/>
|
||
<Input
|
||
{...register(`process.args.${index}.value`)}
|
||
className="mono-input"
|
||
placeholder="参数值 (可选)"
|
||
/>
|
||
<TextButton
|
||
tone="danger"
|
||
onClick={() => processArgs.remove(index)}
|
||
>
|
||
删除
|
||
</TextButton>
|
||
</div>
|
||
))}
|
||
{processArgs.fields.length === 0 ? (
|
||
<Text size="sm" tone="muted">
|
||
暂无启动参数
|
||
</Text>
|
||
) : null}
|
||
{errors.process?.args?.message ? (
|
||
<span className="field-error">
|
||
{errors.process.args.message}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<KeyValueEditor
|
||
title="环境变量"
|
||
name="process.env"
|
||
fields={processEnv.fields}
|
||
registerField={register}
|
||
error={errors.process?.env?.message}
|
||
onAdd={() => processEnv.append({ key: '', value: '' })}
|
||
onRemove={processEnv.remove}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<Controller
|
||
control={control}
|
||
name="enabled"
|
||
render={({ field }) => (
|
||
<Checkbox
|
||
className="form-toggle"
|
||
checked={field.value}
|
||
onChange={field.onChange}
|
||
label={
|
||
<span className="form-toggle-copy">
|
||
<Text strong size="sm">
|
||
立即激活此任务调度
|
||
</Text>
|
||
<Text size="xs" tone="muted">
|
||
保存后立即注册到 Cron 调度器
|
||
</Text>
|
||
</span>
|
||
}
|
||
/>
|
||
)}
|
||
/>
|
||
</form>
|
||
|
||
<DirectoryPickerDialog
|
||
open={pickerOpen}
|
||
initialPath={scriptPreview.working_dir}
|
||
onSelect={(path) => {
|
||
clearAutoVenv()
|
||
setValue('script.working_dir', path, {
|
||
shouldDirty: true,
|
||
shouldValidate: true,
|
||
})
|
||
}}
|
||
onClose={() => setPickerOpen(false)}
|
||
/>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
||
const values = argumentsList.flatMap(({ key, value }) => {
|
||
const argument = key.trim()
|
||
const argumentValue = value.trim()
|
||
return [
|
||
argument,
|
||
argumentValue.includes(' ') ? `"${argumentValue}"` : argumentValue,
|
||
].filter(Boolean)
|
||
})
|
||
return values.length ? ` ${values.join(' ')}` : ''
|
||
}
|
||
|
||
/**
|
||
* 探测结果给的是绝对路径;虚拟环境位于工作目录内时写成相对路径,
|
||
* 任务搬家后仍然可用(与手填 `.venv` 的语义一致)。
|
||
*/
|
||
function venvFieldValue(workingDir: string, venvPath: string): string {
|
||
const base = workingDir.trim().replace(/[\\/]+$/, '')
|
||
const path = venvPath.trim()
|
||
if (!base || !path) {
|
||
return path
|
||
}
|
||
const normalizedBase = base.toLowerCase()
|
||
const normalizedPath = path.toLowerCase()
|
||
if (normalizedPath === normalizedBase) {
|
||
return '.'
|
||
}
|
||
if (
|
||
normalizedPath.startsWith(`${normalizedBase}\\`) ||
|
||
normalizedPath.startsWith(`${normalizedBase}/`)
|
||
) {
|
||
return path.slice(base.length + 1).replace(/\//g, '\\')
|
||
}
|
||
return path
|
||
}
|
||
|
||
function formatInterpreterPreview(
|
||
script: TaskFormValues['script'],
|
||
linkedPythonPath: string | null | undefined,
|
||
language: ScriptLanguage,
|
||
): string {
|
||
if (language === 'python' && script.venv.trim()) {
|
||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
||
}
|
||
if (linkedPythonPath) {
|
||
return linkedPythonPath
|
||
}
|
||
if (script.interpreter.trim()) {
|
||
return script.interpreter.trim()
|
||
}
|
||
return language === 'python'
|
||
? 'python.exe'
|
||
: SCRIPT_LANGUAGE_META[language].defaultInterpreter
|
||
}
|
||
|
||
function isCmdInterpreter(interpreter: string): boolean {
|
||
return (
|
||
interpreter
|
||
.trim()
|
||
.split(/[\\/]/)
|
||
.pop()
|
||
?.toLowerCase()
|
||
.replace(/\.exe$/, '') === 'cmd'
|
||
)
|
||
}
|
||
|
||
function formatScriptCommandPreview(
|
||
script: TaskFormValues['script'],
|
||
linkedPythonPath: string | null | undefined,
|
||
language: ScriptLanguage,
|
||
): string {
|
||
const interpreter = formatInterpreterPreview(
|
||
script,
|
||
linkedPythonPath,
|
||
language,
|
||
)
|
||
const file =
|
||
script.script.trim() || SCRIPT_LANGUAGE_META[language].defaultScript
|
||
const cmdPrefix =
|
||
language === 'shell' &&
|
||
isCmdInterpreter(script.interpreter) &&
|
||
!file.startsWith('/')
|
||
? '/C '
|
||
: ''
|
||
return `${interpreter} ${cmdPrefix}${file}${formatArgumentPreview(script.args)}`
|
||
}
|
||
|
||
interface VenvDetectorBarProps {
|
||
workingDir: string
|
||
probe: VenvProbeResponse | undefined
|
||
pending: boolean
|
||
failed: boolean
|
||
creating: boolean
|
||
onCreate: () => void
|
||
}
|
||
|
||
function VenvDetectorBar({
|
||
workingDir,
|
||
probe,
|
||
pending,
|
||
failed,
|
||
creating,
|
||
onCreate,
|
||
}: VenvDetectorBarProps) {
|
||
if (!workingDir.trim()) {
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-detector-hint">填写工作目录后自动检测环境</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (pending) {
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge pending">⏳ 正在探测解释器...</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (failed || !probe) {
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (probe.status === 'linked') {
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge ok">
|
||
● 已关联虚拟环境: <code>{probe.display_path}</code>
|
||
</span>
|
||
<span className="env-detector-hint">
|
||
Rust 自动前置 PATH 及 VIRTUAL_ENV
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (probe.status === 'not_applicable') {
|
||
const label =
|
||
probe.interpreter_kind === 'node' ? '识别为 Node.js' : '使用指定解释器'
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge ok">
|
||
● {label}: <code>{probe.display_path}</code>
|
||
</span>
|
||
<span className="env-detector-hint">{probe.message}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (probe.status === 'missing') {
|
||
if (probe.can_create) {
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<div className="env-detector-message">
|
||
<span className="env-status-badge warn">● {probe.message}</span>
|
||
<span className="env-detector-hint">
|
||
{probe.has_pyproject
|
||
? 'pyproject.toml'
|
||
: probe.requirements_files.join(' · ')}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
loading={creating}
|
||
onClick={onCreate}
|
||
>
|
||
用 uv 创建环境
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge danger">● {probe.message}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="env-detector-bar" role="status">
|
||
<span className="env-status-badge warn">● {probe.message}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function isSensitiveName(name: string): boolean {
|
||
const normalized = name.toUpperCase()
|
||
return [
|
||
'SECRET',
|
||
'TOKEN',
|
||
'PASSWORD',
|
||
'PASS',
|
||
'API_KEY',
|
||
'PRIVATE_KEY',
|
||
'CREDENTIAL',
|
||
'AUTHORIZATION',
|
||
'AUTH',
|
||
].some((marker) => normalized.includes(marker))
|
||
}
|
||
|
||
interface KeyValueEditorProps {
|
||
title: string
|
||
name: 'script.env' | 'http.headers' | 'process.env'
|
||
fields: Array<{ id: string; key: string; value: string }>
|
||
registerField: UseFormRegister<TaskFormValues>
|
||
onAdd: () => void
|
||
onRemove: (index: number) => void
|
||
error?: ReactNode
|
||
}
|
||
|
||
function KeyValueEditor({
|
||
title,
|
||
name,
|
||
fields,
|
||
registerField,
|
||
onAdd,
|
||
onRemove,
|
||
error,
|
||
}: KeyValueEditorProps) {
|
||
return (
|
||
<div className="key-value-editor">
|
||
<div className="collection-header">
|
||
<div>
|
||
<Text strong size="sm">
|
||
{title}
|
||
</Text>
|
||
<Text size="xs" tone="muted">
|
||
名称和值会原样提交给后端
|
||
</Text>
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={onAdd}>
|
||
+ 添加
|
||
</Button>
|
||
</div>
|
||
<div className="collection-list">
|
||
{fields.map((field, index) => (
|
||
<div className="key-value-row" key={field.id}>
|
||
<Input
|
||
{...registerField(
|
||
`${name}.${index}.key` as FieldPath<TaskFormValues>,
|
||
)}
|
||
className="mono-input"
|
||
placeholder="名称"
|
||
/>
|
||
<PasswordInput
|
||
{...registerField(
|
||
`${name}.${index}.value` as FieldPath<TaskFormValues>,
|
||
)}
|
||
icon={null}
|
||
className="mono-input"
|
||
placeholder="值"
|
||
autoComplete="off"
|
||
/>
|
||
<TextButton tone="danger" onClick={() => onRemove(index)}>
|
||
删除
|
||
</TextButton>
|
||
</div>
|
||
))}
|
||
{fields.length === 0 ? (
|
||
<Text size="sm" tone="muted">
|
||
暂无配置
|
||
</Text>
|
||
) : null}
|
||
{error ? <span className="field-error">{error}</span> : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|