Files
task_pilot/frontend/src/components/TaskEditorModal.tsx
T
CNWei 8e6b3b92b9 feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create
- 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像
- 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错
- 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器
- 补后端探测/创建测试、前端入口测试与 README
2026-11-03 22:57:17 +08:00

1077 lines
33 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,
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<void>
}
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_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')
// 当前只开放 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)
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.trim() || 'python.exe',
venv: debouncedVenv.trim() || null,
},
signal,
),
enabled:
open && actionType === 'script' && 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 = 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)
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) {
return
}
const probe = venvProbe.data
if (!probe || probe.status !== 'linked' || !probe.venv_path) {
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, open, 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="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"
{...register('script.working_dir')}
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>
<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>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
creating={createVenvMutation.isPending}
onCreate={() =>
createVenvMutation.mutate(scriptPreview.working_dir.trim())
}
/>
<FormField
label="脚本文件或相对路径"
htmlFor="task-script"
error={errors.script?.script?.message}
>
<Input
id="task-script"
{...register('script.script')}
className="mono-input"
placeholder="main.py 或 scripts\sync.py"
/>
</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">
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
{scriptPreview.script.trim() || 'main.py'}
{formatArgumentPreview(scriptPreview.args)}
</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) => {
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,
): string {
if (script.venv.trim()) {
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
}
return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe')
}
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-status-badge warn">
● 请输入有效的工作目录以探测环境
</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>
)
}