From 62f1661b2cf7bfc26c63fcc04fe724c7e7c8b1ed Mon Sep 17 00:00:00 2001 From: CNWei Date: Wed, 4 Nov 2026 23:20:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E5=88=87=E6=8D=A2=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E5=8F=AA=E4=BF=9D=E7=95=99=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E7=8E=AF=E5=A2=83=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换目录时先同步清掉旧目录自动填入的 venv;实时 venv 为空时立即忽略防抖旧值;旧查询结果不再回填新目录;补充 Playwright 回归用例。 --- README.md | 2 +- frontend/e2e/dashboard.spec.ts | 27 ++++++++++++ frontend/src/components/TaskEditorModal.tsx | 48 ++++++++++++++++----- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d962c50..0e669b1 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py | Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | | Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 | | 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | -| Playwright(7) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | +| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | --- diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 4ffb527..2584dfa 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -204,6 +204,33 @@ test('switches script language and adapts the form fields', async ({ page }) => 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() + 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 ({ page, request, diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index 1d38891..9d67ed4 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -136,6 +136,10 @@ export function TaskEditorModal({ 350, ) const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350) + // 切换工作目录时会同步清掉自动填入的 venv;如果实时值已经为空, + // 探测必须立刻当作没有 venv,不能继续用防抖中的旧值, + // 否则会先报“缺少 pyvenv.cfg”,再跳到“未检测到虚拟环境”。 + const probeVenv = scriptPreview.venv.trim() ? debouncedVenv : '' const cronPreview = useQuery({ queryKey: [ '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({ queryKey: [ 'python-venv', debouncedWorkingDir, debouncedInterpreter, - debouncedVenv, + probeVenv, scriptLanguage, ], queryFn: ({ signal }) => @@ -200,7 +216,7 @@ export function TaskEditorModal({ { working_dir: debouncedWorkingDir, interpreter: debouncedInterpreter.trim() || 'python.exe', - venv: debouncedVenv.trim() || null, + venv: probeVenv.trim() || null, }, signal, ), @@ -262,10 +278,13 @@ export function TaskEditorModal({ if (!open) { return } - if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) { + if ( + autoVenvRef.current && + getValues('script.venv').trim() === autoVenvRef.current + ) { setValue('script.venv', '', { shouldDirty: false }) + autoVenvRef.current = '' } - autoVenvRef.current = '' }, [debouncedWorkingDir, getValues, open, setValue]) // 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。 @@ -277,6 +296,10 @@ export function TaskEditorModal({ if (!probe || probe.status !== 'linked' || !probe.venv_path) { return } + // 工作目录还在防抖中时,旧查询结果不能回填到新目录。 + if (debouncedWorkingDir !== scriptPreview.working_dir.trim()) { + return + } const current = getValues('script.venv').trim() // 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」 // 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。 @@ -295,7 +318,7 @@ export function TaskEditorModal({ shouldDirty: true, shouldValidate: true, }) - }, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, setValue, venvProbe.data]) + }, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, scriptPreview.working_dir, setValue, venvProbe.data]) // 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。 // 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。 @@ -561,7 +584,13 @@ export function TaskEditorModal({
{ + // 先清掉旧目录自动填入的 venv,再更新工作目录; + // 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。 + clearAutoVenv() + void workingDirField.onChange(event) + }} className="mono-input" placeholder="D:\Projects\sync" aria-invalid={Boolean(errors.script?.working_dir)} @@ -595,7 +624,7 @@ export function TaskEditorModal({ { + clearAutoVenv() setValue('script.working_dir', path, { shouldDirty: true, shouldValidate: true, @@ -1013,9 +1043,7 @@ function VenvDetectorBar({ if (!workingDir.trim()) { return (
- - ● 请输入有效的工作目录以探测环境 - + 填写工作目录后自动检测环境
) }