- 依赖 @stash/ui(file:../../stash-ui),统一引入 style.css 与 Toaster - 任务面板、表单、弹窗、日志、状态标签全部迁到组件库,App.css 只保留页面布局 - 移除自写 useFocusTrap,改用基于原生 dialog 的 Modal - 修复 LogViewer 流式追加时误判离开底部、导致不再自动跟随的问题 - 发布脚本检查 stash-ui 产物;README 补充组件库构建与 file: 依赖刷新说明
934 lines
28 KiB
TypeScript
934 lines
28 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import {
|
||
Button,
|
||
Checkbox,
|
||
FormField,
|
||
Input,
|
||
Modal,
|
||
Notice,
|
||
PasswordInput,
|
||
Select,
|
||
Text,
|
||
Textarea,
|
||
TextButton,
|
||
} from '@stash/ui'
|
||
import { useEffect, useState, type ReactNode } from 'react'
|
||
import {
|
||
Controller,
|
||
type FieldPath,
|
||
type UseFormRegister,
|
||
useFieldArray,
|
||
useForm,
|
||
} from 'react-hook-form'
|
||
import {
|
||
ApiRequestError,
|
||
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_SUGGESTIONS = [
|
||
'Asia/Shanghai',
|
||
'Asia/Hong_Kong',
|
||
'UTC',
|
||
'Europe/London',
|
||
'America/New_York',
|
||
]
|
||
|
||
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 {
|
||
register,
|
||
control,
|
||
handleSubmit,
|
||
reset,
|
||
watch,
|
||
setError,
|
||
setValue,
|
||
formState: { errors },
|
||
} = useForm<TaskFormValues>({
|
||
resolver: zodResolver(taskFormSchema),
|
||
defaultValues: createEmptyTaskFormValues(),
|
||
mode: 'onBlur',
|
||
})
|
||
const actionType = watch('actionType')
|
||
const cronValue = watch('cron')
|
||
const timezoneValue = watch('timezone')
|
||
const scriptPreview = watch('script')
|
||
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,
|
||
venv: debouncedVenv.trim() || null,
|
||
},
|
||
signal,
|
||
),
|
||
enabled:
|
||
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
|
||
retry: false,
|
||
staleTime: 15_000,
|
||
})
|
||
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)
|
||
}, [open, reset, task])
|
||
|
||
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 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)}
|
||
/>
|
||
<div className="cron-preview-box" aria-live="polite">
|
||
<div className="cron-preview-head">
|
||
<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>
|
||
{cronPreview.isFetching ? (
|
||
<Text size="sm" tone="sub">
|
||
正在计算...
|
||
</Text>
|
||
) : cronPreview.isError ? (
|
||
<Text size="sm" className="cron-preview-error">
|
||
{cronPreview.error.message}
|
||
</Text>
|
||
) : cronPreview.data?.runs.length ? (
|
||
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>
|
||
))
|
||
) : (
|
||
<Text size="sm" tone="sub">
|
||
等待输入有效 Cron 表达式...
|
||
</Text>
|
||
)}
|
||
</div>
|
||
</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>
|
||
|
||
<div className="form-grid form-grid-single">
|
||
<FormField
|
||
label="IANA 时区"
|
||
htmlFor="task-timezone"
|
||
error={errors.timezone?.message}
|
||
>
|
||
<Input
|
||
id="task-timezone"
|
||
{...register('timezone')}
|
||
list="timezone-options"
|
||
placeholder="Asia/Shanghai"
|
||
aria-invalid={Boolean(errors.timezone)}
|
||
/>
|
||
<datalist id="timezone-options">
|
||
{TIMEZONE_SUGGESTIONS.map((zone) => (
|
||
<option value={zone} key={zone} />
|
||
))}
|
||
</datalist>
|
||
</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>
|
||
|
||
<VenvDetectorBar
|
||
workingDir={debouncedWorkingDir}
|
||
probe={venvProbe.data}
|
||
pending={venvProbe.isPending}
|
||
failed={venvProbe.isError}
|
||
/>
|
||
|
||
<div className="form-grid">
|
||
<FormField
|
||
label="解释器"
|
||
htmlFor="task-interpreter"
|
||
error={errors.script?.interpreter?.message}
|
||
>
|
||
<Input
|
||
id="task-interpreter"
|
||
{...register('script.interpreter')}
|
||
className="mono-input"
|
||
placeholder="python.exe"
|
||
/>
|
||
</FormField>
|
||
<FormField
|
||
label="虚拟环境目录 (可选)"
|
||
htmlFor="task-venv"
|
||
hint="留空时自动探测;填写后优先使用该目录"
|
||
error={errors.script?.venv?.message}
|
||
>
|
||
<Input
|
||
id="task-venv"
|
||
{...register('script.venv')}
|
||
className="mono-input"
|
||
placeholder="例如:.venv"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
<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(' ')}` : ''
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function VenvDetectorBar({
|
||
workingDir,
|
||
probe,
|
||
pending,
|
||
failed,
|
||
}: 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') {
|
||
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">{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>
|
||
)
|
||
}
|