feat(frontend): 自动回填并支持修改虚拟环境目录
- 虚拟环境目录与解释器移到工作目录正下方,虚拟环境目录优先且更宽 - 探测到虚拟环境时自动填入(工作目录内用相对路径 .venv),仍可手动修改 - 手动改过的值不再被探测结果覆盖;换工作目录只清理上一轮自动填入的值并重新探测 - 防抖期间忽略过期探测结果,避免旧环境被回填
This commit is contained in:
@@ -211,6 +211,11 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
|
||||||
|
.venv-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||||
|
}
|
||||||
|
|
||||||
.form-select {
|
.form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -889,6 +894,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.venv-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.cron-run-grid {
|
.cron-run-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
TextButton,
|
TextButton,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
@@ -87,9 +87,13 @@ export function TaskEditorModal({
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||||
|
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||||
|
const autoVenvRef = useRef('')
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
getValues,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
@@ -181,8 +185,49 @@ export function TaskEditorModal({
|
|||||||
setCronPreviewCount(5)
|
setCronPreviewCount(5)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setPickerOpen(false)
|
setPickerOpen(false)
|
||||||
|
autoVenvRef.current = ''
|
||||||
}, [open, reset, task])
|
}, [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 已锁定,跳名称。
|
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||||
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -442,14 +487,20 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<VenvDetectorBar
|
<div className="form-grid venv-grid">
|
||||||
workingDir={debouncedWorkingDir}
|
<FormField
|
||||||
probe={venvProbe.data}
|
label="虚拟环境目录 (可选)"
|
||||||
pending={venvProbe.isPending}
|
htmlFor="task-venv"
|
||||||
failed={venvProbe.isError}
|
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
||||||
/>
|
error={errors.script?.venv?.message}
|
||||||
|
>
|
||||||
<div className="form-grid">
|
<Input
|
||||||
|
id="task-venv"
|
||||||
|
{...register('script.venv')}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder="例如:.venv"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label="解释器"
|
label="解释器"
|
||||||
htmlFor="task-interpreter"
|
htmlFor="task-interpreter"
|
||||||
@@ -462,21 +513,15 @@ export function TaskEditorModal({
|
|||||||
placeholder="python.exe"
|
placeholder="python.exe"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<VenvDetectorBar
|
||||||
|
workingDir={debouncedWorkingDir}
|
||||||
|
probe={venvProbe.data}
|
||||||
|
pending={venvProbe.isPending}
|
||||||
|
failed={venvProbe.isError}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="脚本文件或相对路径"
|
label="脚本文件或相对路径"
|
||||||
htmlFor="task-script"
|
htmlFor="task-script"
|
||||||
@@ -786,6 +831,30 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
|||||||
return values.length ? ` ${values.join(' ')}` : ''
|
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(
|
function formatInterpreterPreview(
|
||||||
script: TaskFormValues['script'],
|
script: TaskFormValues['script'],
|
||||||
linkedPythonPath?: string | null,
|
linkedPythonPath?: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user