Files
task_pilot/frontend/src/components/TaskEditorModal.tsx
T
CNWei 3c1a603b90 style(frontend): 接入 stash-ui 设计系统
- 依赖 @stash/ui(file:../../stash-ui),统一引入 style.css 与 Toaster
- 任务面板、表单、弹窗、日志、状态标签全部迁到组件库,App.css 只保留页面布局
- 移除自写 useFocusTrap,改用基于原生 dialog 的 Modal
- 修复 LogViewer 流式追加时误判离开底部、导致不再自动跟随的问题
- 发布脚本检查 stash-ui 产物;README 补充组件库构建与 file: 依赖刷新说明
2026-11-01 23:54:35 +08:00

934 lines
28 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 { 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>
)
}