fix(frontend): 切换目录时只保留一次虚拟环境状态

切换目录时先同步清掉旧目录自动填入的 venv;实时 venv 为空时立即忽略防抖旧值;旧查询结果不再回填新目录;补充 Playwright 回归用例。
This commit is contained in:
2026-11-04 23:20:54 +08:00
parent 8501deba07
commit 62f1661b2c
3 changed files with 66 additions and 11 deletions
+1 -1
View File
@@ -550,7 +550,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | | Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 | | Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 |
| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | | 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(7) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
--- ---
+27
View File
@@ -204,6 +204,33 @@ test('switches script language and adapts the form fields', async ({ page }) =>
await dialog.getByRole('button', { name: '取消' }).click() await dialog.getByRole('button', { name: '取消' }).click()
}) })
test('avoids a transient invalid venv when switching directories', async ({
page,
}) => {
const venvDir = createVenvFixture()
const emptyProject = join(venvDir, '..', 'empty-project')
mkdirSync(emptyProject, { recursive: true })
await page.goto('/')
await page.getByRole('button', { name: '+ 新建任务' }).click()
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
const workingDir = dialog.getByLabel(/项目工作目录/)
const bar = dialog.locator('.env-detector-bar')
await workingDir.fill(venvDir)
await expect(bar).toContainText('已关联虚拟环境')
await workingDir.fill(emptyProject)
const states = new Set<string>()
for (let index = 0; index < 30; index += 1) {
states.add(await bar.innerText())
await page.waitForTimeout(80)
}
expect([...states].some((text) => text.includes('缺少'))).toBe(false)
await expect(bar).toContainText('未检测到虚拟环境')
})
test('keeps the dashboard usable on a narrow viewport', async ({ test('keeps the dashboard usable on a narrow viewport', async ({
page, page,
request, request,
+38 -10
View File
@@ -136,6 +136,10 @@ export function TaskEditorModal({
350, 350,
) )
const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350) const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350)
// 切换工作目录时会同步清掉自动填入的 venv;如果实时值已经为空,
// 探测必须立刻当作没有 venv,不能继续用防抖中的旧值,
// 否则会先报“缺少 pyvenv.cfg”,再跳到“未检测到虚拟环境”。
const probeVenv = scriptPreview.venv.trim() ? debouncedVenv : ''
const cronPreview = useQuery({ const cronPreview = useQuery({
queryKey: [ queryKey: [
'cron-preview', 'cron-preview',
@@ -187,12 +191,24 @@ export function TaskEditorModal({
} }
} }
const clearAutoVenv = () => {
if (
autoVenvRef.current &&
getValues('script.venv').trim() === autoVenvRef.current
) {
setValue('script.venv', '', { shouldDirty: false })
autoVenvRef.current = ''
}
}
const workingDirField = register('script.working_dir')
const venvProbe = useQuery({ const venvProbe = useQuery({
queryKey: [ queryKey: [
'python-venv', 'python-venv',
debouncedWorkingDir, debouncedWorkingDir,
debouncedInterpreter, debouncedInterpreter,
debouncedVenv, probeVenv,
scriptLanguage, scriptLanguage,
], ],
queryFn: ({ signal }) => queryFn: ({ signal }) =>
@@ -200,7 +216,7 @@ export function TaskEditorModal({
{ {
working_dir: debouncedWorkingDir, working_dir: debouncedWorkingDir,
interpreter: debouncedInterpreter.trim() || 'python.exe', interpreter: debouncedInterpreter.trim() || 'python.exe',
venv: debouncedVenv.trim() || null, venv: probeVenv.trim() || null,
}, },
signal, signal,
), ),
@@ -262,10 +278,13 @@ export function TaskEditorModal({
if (!open) { if (!open) {
return return
} }
if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) { if (
autoVenvRef.current &&
getValues('script.venv').trim() === autoVenvRef.current
) {
setValue('script.venv', '', { shouldDirty: false }) setValue('script.venv', '', { shouldDirty: false })
}
autoVenvRef.current = '' autoVenvRef.current = ''
}
}, [debouncedWorkingDir, getValues, open, setValue]) }, [debouncedWorkingDir, getValues, open, setValue])
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。 // 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
@@ -277,6 +296,10 @@ export function TaskEditorModal({
if (!probe || probe.status !== 'linked' || !probe.venv_path) { if (!probe || probe.status !== 'linked' || !probe.venv_path) {
return return
} }
// 工作目录还在防抖中时,旧查询结果不能回填到新目录。
if (debouncedWorkingDir !== scriptPreview.working_dir.trim()) {
return
}
const current = getValues('script.venv').trim() const current = getValues('script.venv').trim()
// 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」 // 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」
// 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。 // 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。
@@ -295,7 +318,7 @@ export function TaskEditorModal({
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
}) })
}, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, setValue, venvProbe.data]) }, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, scriptPreview.working_dir, setValue, venvProbe.data])
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。 // 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。 // 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
@@ -561,7 +584,13 @@ export function TaskEditorModal({
<div className="field-with-action"> <div className="field-with-action">
<Input <Input
id="task-working-dir" id="task-working-dir"
{...register('script.working_dir')} {...workingDirField}
onChange={(event) => {
// 先清掉旧目录自动填入的 venv,再更新工作目录;
// 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。
clearAutoVenv()
void workingDirField.onChange(event)
}}
className="mono-input" className="mono-input"
placeholder="D:\Projects\sync" placeholder="D:\Projects\sync"
aria-invalid={Boolean(errors.script?.working_dir)} aria-invalid={Boolean(errors.script?.working_dir)}
@@ -595,7 +624,7 @@ export function TaskEditorModal({
</FormField> </FormField>
<VenvDetectorBar <VenvDetectorBar
workingDir={debouncedWorkingDir} workingDir={scriptPreview.working_dir}
probe={venvProbe.data} probe={venvProbe.data}
pending={venvProbe.isPending} pending={venvProbe.isPending}
failed={venvProbe.isError} failed={venvProbe.isError}
@@ -895,6 +924,7 @@ export function TaskEditorModal({
open={pickerOpen} open={pickerOpen}
initialPath={scriptPreview.working_dir} initialPath={scriptPreview.working_dir}
onSelect={(path) => { onSelect={(path) => {
clearAutoVenv()
setValue('script.working_dir', path, { setValue('script.working_dir', path, {
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
@@ -1013,9 +1043,7 @@ function VenvDetectorBar({
if (!workingDir.trim()) { if (!workingDir.trim()) {
return ( return (
<div className="env-detector-bar" role="status"> <div className="env-detector-bar" role="status">
<span className="env-status-badge warn"> <span className="env-detector-hint">填写工作目录后自动检测环境</span>
● 请输入有效的工作目录以探测环境
</span>
</div> </div>
) )
} }