feat(frontend): 自动回填并支持修改虚拟环境目录

- 虚拟环境目录与解释器移到工作目录正下方,虚拟环境目录优先且更宽
- 探测到虚拟环境时自动填入(工作目录内用相对路径 .venv),仍可手动修改
- 手动改过的值不再被探测结果覆盖;换工作目录只清理上一轮自动填入的值并重新探测
- 防抖期间忽略过期探测结果,避免旧环境被回填
This commit is contained in:
2026-11-02 23:08:13 +08:00
parent b7ded69a7d
commit d3f0f57f6d
2 changed files with 100 additions and 22 deletions
+9
View File
@@ -211,6 +211,11 @@
gap: 16px;
}
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
.venv-grid {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
}
.form-select {
width: 100%;
}
@@ -889,6 +894,10 @@
grid-template-columns: 1fr;
}
.venv-grid {
grid-template-columns: 1fr;
}
.cron-run-grid {
grid-template-columns: 1fr;
}
+90 -21
View File
@@ -15,7 +15,7 @@ import {
Textarea,
TextButton,
} from '@stash/ui'
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Controller,
type FieldPath,
@@ -87,9 +87,13 @@ export function TaskEditorModal({
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const [pickerOpen, setPickerOpen] = useState(false)
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
const autoVenvRef = useRef('')
const {
register,
control,
getValues,
handleSubmit,
reset,
watch,
@@ -181,8 +185,49 @@ export function TaskEditorModal({
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(() => {
@@ -442,14 +487,20 @@ export function TaskEditorModal({
</div>
</FormField>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
<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"
/>
<div className="form-grid">
</FormField>
<FormField
label="解释器"
htmlFor="task-interpreter"
@@ -462,21 +513,15 @@ export function TaskEditorModal({
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>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
/>
<FormField
label="脚本文件或相对路径"
htmlFor="task-script"
@@ -786,6 +831,30 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
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,