Files
task_pilot/frontend/src/components/TaskEditorModal.tsx
T
CNWei 62f1661b2c fix(frontend): 切换目录时只保留一次虚拟环境状态
切换目录时先同步清掉旧目录自动填入的 venv;实时 venv 为空时立即忽略防抖旧值;旧查询结果不再回填新目录;补充 Playwright 回归用例。
2026-11-04 23:20:54 +08:00

1213 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}