feat: 拆分任务类型并支持脚本语言联动

- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua)
- 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览
- 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器
- TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
This commit is contained in:
2026-11-04 22:07:14 +08:00
parent 8e6b3b92b9
commit 917674880b
15 changed files with 513 additions and 79 deletions
+178 -55
View File
@@ -36,9 +36,11 @@ 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'
@@ -52,11 +54,17 @@ interface TaskEditorModalProps {
}
const ACTION_TYPE_OPTIONS = [
{ value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' },
{ value: 'script', label: '执行脚本' },
{ value: 'http', label: '发起 HTTP 请求' },
{ value: 'process', label: '管理常驻/独立二进制' },
{ 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' },
@@ -109,6 +117,9 @@ export function TaskEditorModal({
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(
@@ -147,12 +158,42 @@ export function TaskEditorModal({
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 venvProbe = useQuery({
queryKey: [
'python-venv',
debouncedWorkingDir,
debouncedInterpreter,
debouncedVenv,
scriptLanguage,
],
queryFn: ({ signal }) =>
probePythonVenv(
@@ -164,7 +205,10 @@ export function TaskEditorModal({
signal,
),
enabled:
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
open &&
actionType === 'script' &&
isPythonScript &&
Boolean(debouncedWorkingDir.trim()),
retry: false,
staleTime: 15_000,
})
@@ -185,10 +229,16 @@ export function TaskEditorModal({
},
onError: (error) => toast.error(error.message),
})
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
const previewVirtualEnv =
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
const previewInterpreter = linkedVenv?.display_path ?? null
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' })
@@ -220,7 +270,7 @@ export function TaskEditorModal({
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
useEffect(() => {
if (!open) {
if (!open || !isPythonScript) {
return
}
const probe = venvProbe.data
@@ -245,7 +295,7 @@ export function TaskEditorModal({
shouldDirty: true,
shouldValidate: true,
})
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
}, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, setValue, venvProbe.data])
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
@@ -475,6 +525,32 @@ export function TaskEditorModal({
</FormField>
</div>
{actionType === 'script' ? (
<div className="form-grid language-grid">
<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 className="language-hint">
<Text size="sm" tone="sub">
{scriptMeta.hint}
</Text>
</div>
</div>
) : null}
{actionType === 'script' ? (
<div className="dynamic-fields">
<FormField
@@ -502,48 +578,52 @@ export function TaskEditorModal({
</div>
</FormField>
<div className="form-grid venv-grid">
<FormField
label="虚拟环境目录 (可选)"
htmlFor="task-venv"
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
error={errors.script?.venv?.message}
>
<Input
id="task-venv"
{...register('script.venv')}
className="mono-input"
placeholder="例如:.venv"
/>
</FormField>
<FormField
label="解释器(可选)"
htmlFor="task-interpreter"
hint="Python 项目留空即可,自动使用虚拟环境里的 Python;Node / Shell 等再填"
error={errors.script?.interpreter?.message}
>
<Input
id="task-interpreter"
{...register('script.interpreter')}
className="mono-input"
placeholder="留空自动使用虚拟环境 Python"
/>
</FormField>
</div>
{isPythonScript ? (
<>
<div className="form-grid venv-grid">
<FormField
label="虚拟环境目录 (可选)"
htmlFor="task-venv"
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
error={errors.script?.venv?.message}
>
<Input
id="task-venv"
{...register('script.venv')}
className="mono-input"
placeholder="例如:.venv"
/>
</FormField>
<FormField
label="解释器(可选)"
htmlFor="task-interpreter"
hint="留空自动使用虚拟环境 Python;也可以填自定义 Python 路径"
error={errors.script?.interpreter?.message}
>
<Input
id="task-interpreter"
{...register('script.interpreter')}
className="mono-input"
placeholder="留空自动使用虚拟环境 Python"
/>
</FormField>
</div>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
creating={createVenvMutation.isPending}
onCreate={() =>
createVenvMutation.mutate(scriptPreview.working_dir.trim())
}
/>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
creating={createVenvMutation.isPending}
onCreate={() =>
createVenvMutation.mutate(scriptPreview.working_dir.trim())
}
/>
</>
) : null}
<FormField
label="脚本文件或相对路径"
label={scriptMeta.scriptLabel}
htmlFor="task-script"
error={errors.script?.script?.message}
>
@@ -551,7 +631,7 @@ export function TaskEditorModal({
id="task-script"
{...register('script.script')}
className="mono-input"
placeholder="main.py 或 scripts\sync.py"
placeholder={scriptMeta.scriptPlaceholder}
/>
</FormField>
@@ -637,9 +717,11 @@ export function TaskEditorModal({
</div>
))}
<div className="cli-line-cmd">
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
{scriptPreview.script.trim() || 'main.py'}
{formatArgumentPreview(scriptPreview.args)}
{formatScriptCommandPreview(
scriptPreview,
previewInterpreter,
scriptLanguage,
)}
</div>
</div>
</div>
@@ -877,12 +959,53 @@ function venvFieldValue(workingDir: string, venvPath: string): string {
function formatInterpreterPreview(
script: TaskFormValues['script'],
linkedPythonPath?: string | null,
linkedPythonPath: string | null | undefined,
language: ScriptLanguage,
): string {
if (script.venv.trim()) {
if (language === 'python' && script.venv.trim()) {
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
}
return linkedPythonPath ?? (script.interpreter.trim() || '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 {