feat: 支持 uv 从 pyproject/requirements 创建虚拟环境

- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create
- 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像
- 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错
- 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器
- 补后端探测/创建测试、前端入口测试与 README
This commit is contained in:
2026-11-03 22:57:17 +08:00
parent 29fc579275
commit 8e6b3b92b9
14 changed files with 759 additions and 70 deletions
+56 -8
View File
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Checkbox,
@@ -14,6 +14,7 @@ import {
Text,
Textarea,
TextButton,
toast,
} from '@stash/ui'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
@@ -25,6 +26,7 @@ import {
} from 'react-hook-form'
import {
ApiRequestError,
createPythonVenv,
previewCron,
probePythonVenv,
type Task,
@@ -81,6 +83,7 @@ export function TaskEditorModal({
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const [pickerOpen, setPickerOpen] = useState(false)
const queryClient = useQueryClient()
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
const autoVenvRef = useRef('')
@@ -155,7 +158,7 @@ export function TaskEditorModal({
probePythonVenv(
{
working_dir: debouncedWorkingDir,
interpreter: debouncedInterpreter,
interpreter: debouncedInterpreter.trim() || 'python.exe',
venv: debouncedVenv.trim() || null,
},
signal,
@@ -165,6 +168,23 @@ export function TaskEditorModal({
retry: false,
staleTime: 15_000,
})
const createVenvMutation = useMutation({
mutationFn: (workingDir: string) =>
createPythonVenv({ working_dir: workingDir }),
onSuccess: (probe, workingDir) => {
toast.success('虚拟环境已创建')
if (probe.venv_path) {
const value = venvFieldValue(workingDir, probe.venv_path)
autoVenvRef.current = value
setValue('script.venv', value, {
shouldDirty: true,
shouldValidate: true,
})
}
void queryClient.invalidateQueries({ queryKey: ['python-venv'] })
},
onError: (error) => toast.error(error.message),
})
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
const previewVirtualEnv =
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
@@ -497,15 +517,16 @@ export function TaskEditorModal({
/>
</FormField>
<FormField
label="解释器"
label="解释器(可选)"
htmlFor="task-interpreter"
hint="Python 项目留空即可,自动使用虚拟环境里的 Python;Node / Shell 等再填"
error={errors.script?.interpreter?.message}
>
<Input
id="task-interpreter"
{...register('script.interpreter')}
className="mono-input"
placeholder="python.exe"
placeholder="留空自动使用虚拟环境 Python"
/>
</FormField>
</div>
@@ -515,6 +536,10 @@ export function TaskEditorModal({
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
creating={createVenvMutation.isPending}
onCreate={() =>
createVenvMutation.mutate(scriptPreview.working_dir.trim())
}
/>
<FormField
@@ -865,6 +890,8 @@ interface VenvDetectorBarProps {
probe: VenvProbeResponse | undefined
pending: boolean
failed: boolean
creating: boolean
onCreate: () => void
}
function VenvDetectorBar({
@@ -872,6 +899,8 @@ function VenvDetectorBar({
probe,
pending,
failed,
creating,
onCreate,
}: VenvDetectorBarProps) {
if (!workingDir.trim()) {
return (
@@ -926,12 +955,31 @@ function VenvDetectorBar({
}
if (probe.status === 'missing') {
if (probe.can_create) {
return (
<div className="env-detector-bar" role="status">
<div className="env-detector-message">
<span className="env-status-badge warn">● {probe.message}</span>
<span className="env-detector-hint">
{probe.has_pyproject
? 'pyproject.toml'
: probe.requirements_files.join(' · ')}
</span>
</div>
<Button
size="sm"
variant="secondary"
loading={creating}
onClick={onCreate}
>
用 uv 创建环境
</Button>
</div>
)
}
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>
<span className="env-status-badge danger">● {probe.message}</span>
</div>
)
}