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:
@@ -117,7 +117,7 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
|||||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||||
| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验、默认 PyPI 镜像注入 |
|
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
||||||
| 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
| 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||||
@@ -206,7 +206,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
|
|
||||||
## Python 与虚拟环境
|
## Python 与虚拟环境
|
||||||
|
|
||||||
解析顺序(执行期与编辑器探测共用同一份逻辑):
|
编辑器和执行器共用同一份解析逻辑:
|
||||||
|
|
||||||
1. 手动指定的 `action.venv`
|
1. 手动指定的 `action.venv`
|
||||||
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
|
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
|
||||||
@@ -214,6 +214,19 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
|
|
||||||
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
|
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
|
||||||
|
|
||||||
|
**没有虚拟环境时**:
|
||||||
|
|
||||||
|
- 工作目录根部有 `pyproject.toml` 或 `requirements*.txt`,且 PATH 中能找到 `uv`:
|
||||||
|
编辑器会显示「用 uv 创建环境」按钮,点击后
|
||||||
|
`pyproject.toml` 走 `uv sync`,仅 requirements 走 `uv venv .venv` + `uv pip install -r`。
|
||||||
|
已有 `.venv`/`venv`/`env` 会优先复用,不会重复创建。创建时会使用当前默认 PyPI 镜像
|
||||||
|
(`PIP_INDEX_URL` / `UV_DEFAULT_INDEX`),不会绕过镜像设置。
|
||||||
|
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
|
||||||
|
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
|
||||||
|
Python 任务不再静默回退到系统解释器。
|
||||||
|
- 解释器字段是可选的:留空表示 Python,运行时会自动使用虚拟环境里的 `python.exe`;
|
||||||
|
Node / Shell 等非 Python 脚本再填写具体可执行程序。
|
||||||
|
|
||||||
命中虚拟环境后注入:
|
命中虚拟环境后注入:
|
||||||
|
|
||||||
| 变量 | 行为 |
|
| 变量 | 行为 |
|
||||||
@@ -234,7 +247,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||||
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
||||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览;
|
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览;
|
||||||
探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改。
|
探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。
|
||||||
|
解释器字段可选,留空自动使用虚拟环境 Python。
|
||||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
||||||
@@ -293,7 +307,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
|
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
|
||||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||||
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 |
|
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
|
||||||
|
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
|
||||||
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
|
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
|
||||||
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||||
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
||||||
@@ -391,7 +406,8 @@ Content-Type: application/json
|
|||||||
| `timezone` | 有效 IANA 名称 |
|
| `timezone` | 有效 IANA 名称 |
|
||||||
| `timeout_secs` | 1–86400 |
|
| `timeout_secs` | 1–86400 |
|
||||||
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
||||||
| `action.working_dir` / `interpreter` / `script` | 非空 |
|
| `action.working_dir` / `script` | 非空 |
|
||||||
|
| `action.interpreter` | 可空;留空时服务端默认成 `python.exe`,Python 任务会用虚拟环境解释器 |
|
||||||
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
||||||
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
||||||
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
||||||
@@ -524,9 +540,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
|||||||
|
|
||||||
| 层 | 覆盖 |
|
| 层 | 覆盖 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||||
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
|
| Rust 集成(38) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建 |
|
||||||
| 前端 Vitest(29) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
| 前端 Vitest(30) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||||
| Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
| Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||||
|
|
||||||
@@ -555,6 +571,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
|||||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||||
|
- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。
|
||||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||||
- 错过的调度不会补跑。
|
- 错过的调度不会补跑。
|
||||||
|
|||||||
+34
-1
@@ -18,7 +18,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
||||||
python_env::{
|
python_env::{
|
||||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||||
|
VenvProbeStatus, VenvSource,
|
||||||
},
|
},
|
||||||
python_mirrors::{
|
python_mirrors::{
|
||||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||||
@@ -144,6 +145,32 @@ pub async fn probe_python_venv(
|
|||||||
.map_err(ApiError::internal)
|
.map_err(ApiError::internal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/python/venv/create",
|
||||||
|
tag = "system",
|
||||||
|
request_body = CreatePythonVenvRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
|
||||||
|
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn create_python_venv(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
|
||||||
|
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||||
|
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||||
|
None
|
||||||
|
});
|
||||||
|
python_env::create_project_venv(&request.working_dir, mirror.as_ref())
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/v1/system/directories",
|
path = "/api/v1/system/directories",
|
||||||
@@ -227,6 +254,7 @@ pub async fn list_schedule(
|
|||||||
session,
|
session,
|
||||||
preview_cron,
|
preview_cron,
|
||||||
probe_python_venv,
|
probe_python_venv,
|
||||||
|
create_python_venv,
|
||||||
list_python_mirrors,
|
list_python_mirrors,
|
||||||
get_python_mirror,
|
get_python_mirror,
|
||||||
create_python_mirror,
|
create_python_mirror,
|
||||||
@@ -258,6 +286,7 @@ pub async fn list_schedule(
|
|||||||
CronPreviewResponse,
|
CronPreviewResponse,
|
||||||
VenvProbeRequest,
|
VenvProbeRequest,
|
||||||
VenvProbeResponse,
|
VenvProbeResponse,
|
||||||
|
CreatePythonVenvRequest,
|
||||||
VenvProbeStatus,
|
VenvProbeStatus,
|
||||||
VenvSource,
|
VenvSource,
|
||||||
InterpreterKind,
|
InterpreterKind,
|
||||||
@@ -317,6 +346,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/v1/python/venv",
|
"/api/v1/python/venv",
|
||||||
axum::routing::post(probe_python_venv),
|
axum::routing::post(probe_python_venv),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/python/venv/create",
|
||||||
|
axum::routing::post(create_python_venv),
|
||||||
|
)
|
||||||
.route("/api/v1/system/directories", get(list_directories))
|
.route("/api/v1/system/directories", get(list_directories))
|
||||||
.route("/api/v1/stats", get(task_stats))
|
.route("/api/v1/stats", get(task_stats))
|
||||||
.route("/api/v1/schedule", get(list_schedule))
|
.route("/api/v1/schedule", get(list_schedule))
|
||||||
|
|||||||
+313
-38
@@ -2,14 +2,20 @@ use std::{
|
|||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
env,
|
env,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
io,
|
fs, io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::process::Command;
|
use tokio::{
|
||||||
|
process::Command,
|
||||||
|
time::{Duration, timeout},
|
||||||
|
};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::python_mirrors::model::DefaultPythonMirror;
|
||||||
|
|
||||||
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -230,76 +236,152 @@ pub struct VenvProbeResponse {
|
|||||||
pub display_path: Option<String>,
|
pub display_path: Option<String>,
|
||||||
pub source: Option<VenvSource>,
|
pub source: Option<VenvSource>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// 本机 PATH 中是否能找到 uv
|
||||||
|
pub uv_available: bool,
|
||||||
|
/// 工作目录根部是否存在 pyproject.toml
|
||||||
|
pub has_pyproject: bool,
|
||||||
|
/// 工作目录根部是否存在 requirements*.txt
|
||||||
|
pub has_requirements: bool,
|
||||||
|
/// 探测到的 requirements 文件名(仅根目录)
|
||||||
|
pub requirements_files: Vec<String>,
|
||||||
|
/// 没有虚拟环境时,是否具备用 uv 创建的条件
|
||||||
|
pub can_create: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreatePythonVenvRequest {
|
||||||
|
pub working_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||||
let working_dir = request.working_dir.trim();
|
let working_dir = request.working_dir.trim();
|
||||||
let interpreter = request.interpreter.trim();
|
let interpreter = {
|
||||||
|
let trimmed = request.interpreter.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"python.exe"
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
};
|
||||||
let configured_venv = request
|
let configured_venv = request
|
||||||
.venv
|
.venv
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
let interpreter_kind = classify_interpreter(interpreter);
|
let interpreter_kind = classify_interpreter(interpreter);
|
||||||
|
let uv = uv_available();
|
||||||
|
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||||
|
|
||||||
if working_dir.is_empty() {
|
if working_dir.is_empty() {
|
||||||
return incomplete_probe(interpreter_kind, "请先填写工作目录");
|
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
|
||||||
}
|
|
||||||
if interpreter.is_empty() {
|
|
||||||
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
|
|
||||||
}
|
}
|
||||||
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
if !matches!(interpreter_kind, InterpreterKind::Python) {
|
||||||
return VenvProbeResponse {
|
return respond(VenvProbeResponse {
|
||||||
status: VenvProbeStatus::NotApplicable,
|
status: VenvProbeStatus::NotApplicable,
|
||||||
interpreter_kind,
|
interpreter_kind,
|
||||||
venv_path: None,
|
venv_path: None,
|
||||||
display_path: Some(interpreter.to_owned()),
|
display_path: Some(interpreter.to_owned()),
|
||||||
source: None,
|
source: None,
|
||||||
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
|
||||||
};
|
..base_probe()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let working_directory = match absolute_path(Path::new(working_dir)) {
|
let working_directory = match absolute_path(Path::new(working_dir)) {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
|
return respond(incomplete_probe(
|
||||||
|
interpreter_kind,
|
||||||
|
&format!("工作目录无效: {error}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !working_directory.is_dir() {
|
if !working_directory.is_dir() {
|
||||||
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
|
return respond(incomplete_probe(
|
||||||
|
interpreter_kind,
|
||||||
|
"工作目录不存在,暂时无法探测虚拟环境",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
match resolve_venv(working_dir, interpreter, configured_venv) {
|
respond(
|
||||||
Ok(ResolvedVenv {
|
match resolve_venv(working_dir, interpreter, configured_venv) {
|
||||||
venv: Some(venv),
|
Ok(ResolvedVenv {
|
||||||
source,
|
venv: Some(venv),
|
||||||
}) => VenvProbeResponse {
|
source,
|
||||||
status: VenvProbeStatus::Linked,
|
}) => VenvProbeResponse {
|
||||||
interpreter_kind,
|
status: VenvProbeStatus::Linked,
|
||||||
display_path: Some(python_display_path(&venv, working_dir)),
|
interpreter_kind,
|
||||||
venv_path: Some(venv.to_string_lossy().into_owned()),
|
display_path: Some(python_display_path(&venv, working_dir)),
|
||||||
source,
|
venv_path: Some(venv.to_string_lossy().into_owned()),
|
||||||
message: venv_source_message(source),
|
source,
|
||||||
},
|
message: venv_source_message(source),
|
||||||
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
..base_probe()
|
||||||
status: VenvProbeStatus::Missing,
|
},
|
||||||
interpreter_kind,
|
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
|
||||||
venv_path: None,
|
status: VenvProbeStatus::Missing,
|
||||||
display_path: Some(interpreter.to_owned()),
|
interpreter_kind,
|
||||||
source: None,
|
venv_path: None,
|
||||||
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
display_path: Some(interpreter.to_owned()),
|
||||||
},
|
source: None,
|
||||||
Err(error) => VenvProbeResponse {
|
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
|
||||||
status: VenvProbeStatus::Invalid,
|
..base_probe()
|
||||||
interpreter_kind,
|
},
|
||||||
venv_path: None,
|
Err(error) => VenvProbeResponse {
|
||||||
display_path: None,
|
status: VenvProbeStatus::Invalid,
|
||||||
source: None,
|
interpreter_kind,
|
||||||
message: error.to_string(),
|
venv_path: None,
|
||||||
|
display_path: None,
|
||||||
|
source: None,
|
||||||
|
message: error.to_string(),
|
||||||
|
..base_probe()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_probe() -> VenvProbeResponse {
|
||||||
|
VenvProbeResponse {
|
||||||
|
status: VenvProbeStatus::Incomplete,
|
||||||
|
interpreter_kind: InterpreterKind::Other,
|
||||||
|
venv_path: None,
|
||||||
|
display_path: None,
|
||||||
|
source: None,
|
||||||
|
message: String::new(),
|
||||||
|
uv_available: false,
|
||||||
|
has_pyproject: false,
|
||||||
|
has_requirements: false,
|
||||||
|
requirements_files: Vec::new(),
|
||||||
|
can_create: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn finalize_probe(
|
||||||
|
mut response: VenvProbeResponse,
|
||||||
|
working_dir: &str,
|
||||||
|
uv_available: bool,
|
||||||
|
) -> VenvProbeResponse {
|
||||||
|
let files = scan_project_files(working_dir);
|
||||||
|
response.uv_available = uv_available;
|
||||||
|
response.has_pyproject = files.has_pyproject;
|
||||||
|
response.has_requirements = !files.requirements.is_empty();
|
||||||
|
response.requirements_files = files.requirements;
|
||||||
|
response.can_create = response.status == VenvProbeStatus::Missing
|
||||||
|
&& uv_available
|
||||||
|
&& (response.has_pyproject || response.has_requirements);
|
||||||
|
if response.status == VenvProbeStatus::Missing {
|
||||||
|
response.message = if response.can_create {
|
||||||
|
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
|
||||||
|
} else if !uv_available {
|
||||||
|
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
|
||||||
|
} else {
|
||||||
|
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
|
||||||
VenvProbeResponse {
|
VenvProbeResponse {
|
||||||
status: VenvProbeStatus::Incomplete,
|
status: VenvProbeStatus::Incomplete,
|
||||||
@@ -308,9 +390,177 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
|
|||||||
display_path: None,
|
display_path: None,
|
||||||
source: None,
|
source: None,
|
||||||
message: message.to_owned(),
|
message: message.to_owned(),
|
||||||
|
..base_probe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProjectFiles {
|
||||||
|
pub has_pyproject: bool,
|
||||||
|
pub requirements: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
||||||
|
let mut files = ProjectFiles::default();
|
||||||
|
let Ok(entries) = fs::read_dir(working_dir) else {
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if lower == "pyproject.toml" {
|
||||||
|
files.has_pyproject = true;
|
||||||
|
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
|
||||||
|
files.requirements.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.requirements.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本机 PATH 中是否有可用的 uv。
|
||||||
|
pub fn uv_available() -> bool {
|
||||||
|
let mut command = std::process::Command::new("uv");
|
||||||
|
command
|
||||||
|
.arg("--version")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
command.status().is_ok_and(|status| status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
||||||
|
pub async fn create_project_venv(
|
||||||
|
working_dir: &str,
|
||||||
|
mirror: Option<&DefaultPythonMirror>,
|
||||||
|
) -> Result<VenvProbeResponse, String> {
|
||||||
|
let working_directory =
|
||||||
|
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||||
|
if !working_directory.is_dir() {
|
||||||
|
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let probe_request = VenvProbeRequest {
|
||||||
|
working_dir: working_dir.to_owned(),
|
||||||
|
interpreter: "python.exe".to_owned(),
|
||||||
|
venv: None,
|
||||||
|
};
|
||||||
|
let existing = probe_venv(&probe_request);
|
||||||
|
if existing.status == VenvProbeStatus::Linked {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = scan_project_files(working_dir);
|
||||||
|
if !files.has_pyproject && files.requirements.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !uv_available() {
|
||||||
|
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.has_pyproject {
|
||||||
|
run_uv(&working_directory, &["sync"], mirror).await?;
|
||||||
|
} else {
|
||||||
|
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
|
||||||
|
let python = working_directory
|
||||||
|
.join(".venv")
|
||||||
|
.join("Scripts")
|
||||||
|
.join("python.exe");
|
||||||
|
let python = python.to_string_lossy().into_owned();
|
||||||
|
for requirement in &files.requirements {
|
||||||
|
run_uv(
|
||||||
|
&working_directory,
|
||||||
|
&["pip", "install", "--python", &python, "-r", requirement],
|
||||||
|
mirror,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let probe = probe_venv(&probe_request);
|
||||||
|
if probe.status != VenvProbeStatus::Linked {
|
||||||
|
return Err(format!(
|
||||||
|
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||||
|
probe.message
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(probe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_uv(
|
||||||
|
working_dir: &Path,
|
||||||
|
args: &[&str],
|
||||||
|
mirror: Option<&DefaultPythonMirror>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut command = Command::new("uv");
|
||||||
|
command
|
||||||
|
.args(args)
|
||||||
|
.current_dir(working_dir)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if let Some(mirror) = mirror {
|
||||||
|
command
|
||||||
|
.env("PIP_INDEX_URL", &mirror.index_url)
|
||||||
|
.env("UV_INDEX_URL", &mirror.index_url)
|
||||||
|
.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||||
|
if let Some(host) = mirror
|
||||||
|
.trusted_host
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
command.env("PIP_TRUSTED_HOST", host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
|
||||||
|
let output = timeout(Duration::from_secs(600), command.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
|
||||||
|
.map_err(|error| format!("无法启动 uv: {error}"))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !output.status.success() {
|
||||||
|
let detail = if stderr.trim().is_empty() {
|
||||||
|
stdout.trim()
|
||||||
|
} else {
|
||||||
|
stderr.trim()
|
||||||
|
};
|
||||||
|
return Err(format!(
|
||||||
|
"uv {} 失败: {}",
|
||||||
|
args.join(" "),
|
||||||
|
truncate_output(detail)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(stdout.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_output(value: &str) -> String {
|
||||||
|
const MAX_CHARS: usize = 1200;
|
||||||
|
if value.chars().count() <= MAX_CHARS {
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
|
||||||
|
truncated.push('…');
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
|
||||||
fn venv_source_message(source: Option<VenvSource>) -> String {
|
fn venv_source_message(source: Option<VenvSource>) -> String {
|
||||||
match source {
|
match source {
|
||||||
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
|
||||||
@@ -735,6 +985,31 @@ mod tests {
|
|||||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||||
assert!(response.venv_path.is_none());
|
assert!(response.venv_path.is_none());
|
||||||
|
assert!(!response.can_create);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_reports_project_dependency_files_for_uv_creation() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||||
|
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||||
|
|
||||||
|
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||||
|
assert!(response.has_pyproject);
|
||||||
|
assert!(response.has_requirements);
|
||||||
|
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
|
||||||
|
assert_eq!(response.can_create, response.uv_available);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_does_not_offer_uv_without_dependency_files() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||||
|
|
||||||
|
assert!(!response.can_create);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -451,6 +451,17 @@ impl TaskExecutor {
|
|||||||
let mut notes = Vec::new();
|
let mut notes = Vec::new();
|
||||||
if let Some(venv) = &prepared.virtual_env {
|
if let Some(venv) = &prepared.virtual_env {
|
||||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||||
|
} else if is_python_interpreter(interpreter) {
|
||||||
|
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
||||||
|
let project = crate::python_env::scan_project_files(working_dir);
|
||||||
|
if project.has_pyproject || !project.requirements.is_empty() {
|
||||||
|
return failed_outcome(
|
||||||
|
"项目有 pyproject.toml / requirements.txt,但还没有 .venv;请先在编辑器中用 uv 创建虚拟环境",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failed_outcome(
|
||||||
|
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if is_python_interpreter(interpreter) {
|
if is_python_interpreter(interpreter) {
|
||||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||||
@@ -462,6 +473,9 @@ impl TaskExecutor {
|
|||||||
if !env_has(env, "UV_INDEX_URL") {
|
if !env_has(env, "UV_INDEX_URL") {
|
||||||
command.env("UV_INDEX_URL", &mirror.index_url);
|
command.env("UV_INDEX_URL", &mirror.index_url);
|
||||||
}
|
}
|
||||||
|
if !env_has(env, "UV_DEFAULT_INDEX") {
|
||||||
|
command.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||||
|
}
|
||||||
if let Some(host) = mirror
|
if let Some(host) = mirror
|
||||||
.trusted_host
|
.trusted_host
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiRes
|
|||||||
request.name = request.name.trim().to_owned();
|
request.name = request.name.trim().to_owned();
|
||||||
request.cron = request.cron.trim().to_owned();
|
request.cron = request.cron.trim().to_owned();
|
||||||
request.timezone = request.timezone.trim().to_owned();
|
request.timezone = request.timezone.trim().to_owned();
|
||||||
|
normalize_action(&mut request.action);
|
||||||
|
|
||||||
validate_task_id(&request.id)?;
|
validate_task_id(&request.id)?;
|
||||||
validate_common(
|
validate_common(
|
||||||
@@ -57,6 +58,7 @@ pub async fn update(
|
|||||||
request.name = request.name.trim().to_owned();
|
request.name = request.name.trim().to_owned();
|
||||||
request.cron = request.cron.trim().to_owned();
|
request.cron = request.cron.trim().to_owned();
|
||||||
request.timezone = request.timezone.trim().to_owned();
|
request.timezone = request.timezone.trim().to_owned();
|
||||||
|
normalize_action(&mut request.action);
|
||||||
|
|
||||||
validate_common(
|
validate_common(
|
||||||
&request.name,
|
&request.name,
|
||||||
@@ -180,6 +182,20 @@ fn validate_common(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_action(action: &mut TaskAction) {
|
||||||
|
if let TaskAction::Script {
|
||||||
|
venv, interpreter, ..
|
||||||
|
} = action
|
||||||
|
{
|
||||||
|
if interpreter.trim().is_empty() {
|
||||||
|
*interpreter = "python.exe".to_owned();
|
||||||
|
}
|
||||||
|
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||||
|
*venv = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||||
match action {
|
match action {
|
||||||
TaskAction::Script {
|
TaskAction::Script {
|
||||||
|
|||||||
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||||
|
post_json(app, "/api/v1/python/venv", token, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
|
||||||
|
post_json(app, "/api/v1/python/venv/create", token, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_json(
|
||||||
|
app: &Router,
|
||||||
|
uri: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
payload: Value,
|
||||||
|
) -> (StatusCode, Value) {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
.uri("/api/v1/python/venv")
|
.uri(uri)
|
||||||
.header(header::CONTENT_TYPE, "application/json");
|
.header(header::CONTENT_TYPE, "application/json");
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
builder = builder.header("x-taskpilot-token", token);
|
builder = builder.header("x-taskpilot-token", token);
|
||||||
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
|
|||||||
.into_body()
|
.into_body()
|
||||||
.collect()
|
.collect()
|
||||||
.await
|
.await
|
||||||
.expect("probe body should be readable")
|
.expect("response body should be readable")
|
||||||
.to_bytes();
|
.to_bytes();
|
||||||
|
|
||||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||||
@@ -179,3 +192,77 @@ async fn venv_probe_reports_missing_working_directory() {
|
|||||||
.contains("工作目录")
|
.contains("工作目录")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn venv_probe_reports_project_dependency_files() {
|
||||||
|
let (app, temp_dir) = test_app().await;
|
||||||
|
let token = fetch_token(&app).await;
|
||||||
|
let project = temp_dir.path().join("deps-project");
|
||||||
|
fs::create_dir_all(&project).expect("project directory should be created");
|
||||||
|
fs::write(
|
||||||
|
project.join("pyproject.toml"),
|
||||||
|
"[project]\nname = \"demo\"\n",
|
||||||
|
)
|
||||||
|
.expect("pyproject.toml should be written");
|
||||||
|
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
|
||||||
|
|
||||||
|
let (status, body) = post_probe(
|
||||||
|
&app,
|
||||||
|
Some(&token),
|
||||||
|
json!({
|
||||||
|
"working_dir": project.to_str().expect("project path should be UTF-8"),
|
||||||
|
"interpreter": "python.exe"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["status"], "missing");
|
||||||
|
assert_eq!(body["has_pyproject"], true);
|
||||||
|
assert_eq!(body["has_requirements"], true);
|
||||||
|
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
|
||||||
|
assert_eq!(body["can_create"], body["uv_available"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn venv_create_rejects_project_without_dependency_files() {
|
||||||
|
let (app, temp_dir) = test_app().await;
|
||||||
|
let token = fetch_token(&app).await;
|
||||||
|
let project = temp_dir.path().join("empty-project");
|
||||||
|
fs::create_dir_all(&project).expect("project directory should be created");
|
||||||
|
|
||||||
|
let (status, body) = post_create(
|
||||||
|
&app,
|
||||||
|
Some(&token),
|
||||||
|
json!({
|
||||||
|
"working_dir": project.to_str().expect("project path should be UTF-8")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["code"], "VENV_CREATE_FAILED");
|
||||||
|
assert!(
|
||||||
|
body["message"]
|
||||||
|
.as_str()
|
||||||
|
.expect("message should be a string")
|
||||||
|
.contains("pyproject.toml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn venv_create_requires_management_token() {
|
||||||
|
let (app, temp_dir) = test_app().await;
|
||||||
|
|
||||||
|
let (status, body) = post_create(
|
||||||
|
&app,
|
||||||
|
None,
|
||||||
|
json!({
|
||||||
|
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
assert_eq!(body["code"], "FORBIDDEN");
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ fn script_task(id: &str, name: &str) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_interpreter_defaults_to_python() {
|
||||||
|
let (app, _temp_dir) = test_app().await;
|
||||||
|
let mut payload = script_task("default_interpreter", "Default interpreter");
|
||||||
|
payload["action"]["interpreter"] = json!("");
|
||||||
|
|
||||||
|
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let created = response_json(response).await;
|
||||||
|
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn task_crud_flow_works_end_to_end() {
|
async fn task_crud_flow_works_end_to_end() {
|
||||||
let (app, _temp_dir) = test_app().await;
|
let (app, _temp_dir) = test_app().await;
|
||||||
|
|||||||
+82
-1
@@ -383,6 +383,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/python/venv/create": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"operationId": "create_python_venv",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreatePythonVenvRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Created or reused project virtual environment",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/VenvProbeResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Project cannot create a virtual environment",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/runs/{run_id}": {
|
"/api/v1/runs/{run_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1155,6 +1195,19 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"CreatePythonVenvRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。",
|
||||||
|
"required": [
|
||||||
|
"working_dir"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"working_dir": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"CreateTaskRequest": {
|
"CreateTaskRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1959,9 +2012,18 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"status",
|
"status",
|
||||||
"interpreter_kind",
|
"interpreter_kind",
|
||||||
"message"
|
"message",
|
||||||
|
"uv_available",
|
||||||
|
"has_pyproject",
|
||||||
|
"has_requirements",
|
||||||
|
"requirements_files",
|
||||||
|
"can_create"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"can_create": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "没有虚拟环境时,是否具备用 uv 创建的条件"
|
||||||
|
},
|
||||||
"display_path": {
|
"display_path": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
@@ -1969,12 +2031,27 @@
|
|||||||
],
|
],
|
||||||
"description": "供界面展示的解释器路径;位于工作目录内时使用相对路径"
|
"description": "供界面展示的解释器路径;位于工作目录内时使用相对路径"
|
||||||
},
|
},
|
||||||
|
"has_pyproject": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "工作目录根部是否存在 pyproject.toml"
|
||||||
|
},
|
||||||
|
"has_requirements": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "工作目录根部是否存在 requirements*.txt"
|
||||||
|
},
|
||||||
"interpreter_kind": {
|
"interpreter_kind": {
|
||||||
"$ref": "#/components/schemas/InterpreterKind"
|
"$ref": "#/components/schemas/InterpreterKind"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"requirements_files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "探测到的 requirements 文件名(仅根目录)"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -1988,6 +2065,10 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"$ref": "#/components/schemas/VenvProbeStatus"
|
"$ref": "#/components/schemas/VenvProbeStatus"
|
||||||
},
|
},
|
||||||
|
"uv_available": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "本机 PATH 中是否能找到 uv"
|
||||||
|
},
|
||||||
"venv_path": {
|
"venv_path": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -444,6 +444,17 @@
|
|||||||
color: var(--su-text-muted);
|
color: var(--su-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.env-status-badge.danger {
|
||||||
|
color: var(--su-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-detector-message {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.env-status-badge code {
|
.env-status-badge code {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--su-radius-xs);
|
border-radius: var(--su-radius-xs);
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
|||||||
working_dir: string
|
working_dir: string
|
||||||
}
|
}
|
||||||
const linked = probe.working_dir.toLowerCase().includes('sync')
|
const linked = probe.working_dir.toLowerCase().includes('sync')
|
||||||
|
const creatable = probe.working_dir.toLowerCase().includes('uvproject')
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
status: linked ? 'linked' : 'missing',
|
status: linked ? 'linked' : 'missing',
|
||||||
interpreter_kind: 'python',
|
interpreter_kind: 'python',
|
||||||
@@ -119,7 +120,14 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
|||||||
source: linked ? 'working_directory' : null,
|
source: linked ? 'working_directory' : null,
|
||||||
message: linked
|
message: linked
|
||||||
? '已自动关联工作目录中的虚拟环境'
|
? '已自动关联工作目录中的虚拟环境'
|
||||||
: '未检测到虚拟环境,将直接使用系统解释器',
|
: creatable
|
||||||
|
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
|
||||||
|
: '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv',
|
||||||
|
uv_available: creatable,
|
||||||
|
has_pyproject: creatable,
|
||||||
|
has_requirements: false,
|
||||||
|
requirements_files: [],
|
||||||
|
can_create: creatable,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url.includes('/api/v1/system/directories')) {
|
if (url.includes('/api/v1/system/directories')) {
|
||||||
@@ -264,7 +272,7 @@ describe('TaskDashboard', () => {
|
|||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the system interpreter when no venv is detected', async () => {
|
it('shows an error when no venv or dependency files exist', async () => {
|
||||||
vi.stubGlobal('fetch', createFetchMock())
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
renderApp()
|
||||||
@@ -276,11 +284,29 @@ describe('TaskDashboard', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
|
await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, {
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers uv environment creation when pyproject.toml exists', async () => {
|
||||||
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderApp()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText(/项目工作目录/),
|
||||||
|
'D:\\Projects\\uvproject',
|
||||||
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
await screen.findByRole('button', { name: '用 uv 创建环境' }, {
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('pyproject.toml')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('browses folders in the page and fills the working directory', async () => {
|
it('browses folders in the page and fills the working directory', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
|||||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
||||||
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
|
||||||
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
||||||
|
export type CreatePythonVenvRequest =
|
||||||
|
components['schemas']['CreatePythonVenvRequest']
|
||||||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||||||
export type VenvSource = components['schemas']['VenvSource']
|
export type VenvSource = components['schemas']['VenvSource']
|
||||||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||||
@@ -285,6 +287,16 @@ export function probePythonVenv(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
|
||||||
|
export function createPythonVenv(
|
||||||
|
request: CreatePythonVenvRequest,
|
||||||
|
): Promise<VenvProbeResponse> {
|
||||||
|
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||||
|
|||||||
@@ -116,6 +116,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/python/venv/create": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["create_python_venv"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/runs/{run_id}": {
|
"/api/v1/runs/{run_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -352,6 +368,10 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
trusted_host?: string | null;
|
trusted_host?: string | null;
|
||||||
};
|
};
|
||||||
|
/** @description 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。 */
|
||||||
|
CreatePythonVenvRequest: {
|
||||||
|
working_dir: string;
|
||||||
|
};
|
||||||
CreateTaskRequest: {
|
CreateTaskRequest: {
|
||||||
action: components["schemas"]["TaskAction"];
|
action: components["schemas"]["TaskAction"];
|
||||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||||
@@ -598,12 +618,22 @@ export interface components {
|
|||||||
working_dir: string;
|
working_dir: string;
|
||||||
};
|
};
|
||||||
VenvProbeResponse: {
|
VenvProbeResponse: {
|
||||||
|
/** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */
|
||||||
|
can_create: boolean;
|
||||||
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
|
||||||
display_path?: string | null;
|
display_path?: string | null;
|
||||||
|
/** @description 工作目录根部是否存在 pyproject.toml */
|
||||||
|
has_pyproject: boolean;
|
||||||
|
/** @description 工作目录根部是否存在 requirements*.txt */
|
||||||
|
has_requirements: boolean;
|
||||||
interpreter_kind: components["schemas"]["InterpreterKind"];
|
interpreter_kind: components["schemas"]["InterpreterKind"];
|
||||||
message: string;
|
message: string;
|
||||||
|
/** @description 探测到的 requirements 文件名(仅根目录) */
|
||||||
|
requirements_files: string[];
|
||||||
source?: components["schemas"]["VenvSource"] | null;
|
source?: components["schemas"]["VenvSource"] | null;
|
||||||
status: components["schemas"]["VenvProbeStatus"];
|
status: components["schemas"]["VenvProbeStatus"];
|
||||||
|
/** @description 本机 PATH 中是否能找到 uv */
|
||||||
|
uv_available: boolean;
|
||||||
/** @description 虚拟环境根目录的绝对路径 */
|
/** @description 虚拟环境根目录的绝对路径 */
|
||||||
venv_path?: string | null;
|
venv_path?: string | null;
|
||||||
};
|
};
|
||||||
@@ -939,6 +969,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
create_python_venv: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreatePythonVenvRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created or reused project virtual environment */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VenvProbeResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Project cannot create a virtual environment */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_run: {
|
get_run: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextButton,
|
TextButton,
|
||||||
|
toast,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from 'react-hook-form'
|
} from 'react-hook-form'
|
||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
|
createPythonVenv,
|
||||||
previewCron,
|
previewCron,
|
||||||
probePythonVenv,
|
probePythonVenv,
|
||||||
type Task,
|
type Task,
|
||||||
@@ -81,6 +83,7 @@ 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 queryClient = useQueryClient()
|
||||||
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||||
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||||
const autoVenvRef = useRef('')
|
const autoVenvRef = useRef('')
|
||||||
@@ -155,7 +158,7 @@ export function TaskEditorModal({
|
|||||||
probePythonVenv(
|
probePythonVenv(
|
||||||
{
|
{
|
||||||
working_dir: debouncedWorkingDir,
|
working_dir: debouncedWorkingDir,
|
||||||
interpreter: debouncedInterpreter,
|
interpreter: debouncedInterpreter.trim() || 'python.exe',
|
||||||
venv: debouncedVenv.trim() || null,
|
venv: debouncedVenv.trim() || null,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
@@ -165,6 +168,23 @@ export function TaskEditorModal({
|
|||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 15_000,
|
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 linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
|
||||||
const previewVirtualEnv =
|
const previewVirtualEnv =
|
||||||
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||||
@@ -497,15 +517,16 @@ export function TaskEditorModal({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label="解释器"
|
label="解释器(可选)"
|
||||||
htmlFor="task-interpreter"
|
htmlFor="task-interpreter"
|
||||||
|
hint="Python 项目留空即可,自动使用虚拟环境里的 Python;Node / Shell 等再填"
|
||||||
error={errors.script?.interpreter?.message}
|
error={errors.script?.interpreter?.message}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="task-interpreter"
|
id="task-interpreter"
|
||||||
{...register('script.interpreter')}
|
{...register('script.interpreter')}
|
||||||
className="mono-input"
|
className="mono-input"
|
||||||
placeholder="python.exe"
|
placeholder="留空自动使用虚拟环境 Python"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,6 +536,10 @@ export function TaskEditorModal({
|
|||||||
probe={venvProbe.data}
|
probe={venvProbe.data}
|
||||||
pending={venvProbe.isPending}
|
pending={venvProbe.isPending}
|
||||||
failed={venvProbe.isError}
|
failed={venvProbe.isError}
|
||||||
|
creating={createVenvMutation.isPending}
|
||||||
|
onCreate={() =>
|
||||||
|
createVenvMutation.mutate(scriptPreview.working_dir.trim())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -865,6 +890,8 @@ interface VenvDetectorBarProps {
|
|||||||
probe: VenvProbeResponse | undefined
|
probe: VenvProbeResponse | undefined
|
||||||
pending: boolean
|
pending: boolean
|
||||||
failed: boolean
|
failed: boolean
|
||||||
|
creating: boolean
|
||||||
|
onCreate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function VenvDetectorBar({
|
function VenvDetectorBar({
|
||||||
@@ -872,6 +899,8 @@ function VenvDetectorBar({
|
|||||||
probe,
|
probe,
|
||||||
pending,
|
pending,
|
||||||
failed,
|
failed,
|
||||||
|
creating,
|
||||||
|
onCreate,
|
||||||
}: VenvDetectorBarProps) {
|
}: VenvDetectorBarProps) {
|
||||||
if (!workingDir.trim()) {
|
if (!workingDir.trim()) {
|
||||||
return (
|
return (
|
||||||
@@ -926,12 +955,31 @@ function VenvDetectorBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (probe.status === 'missing') {
|
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 (
|
return (
|
||||||
<div className="env-detector-bar" role="status">
|
<div className="env-detector-bar" role="status">
|
||||||
<span className="env-status-badge ok">
|
<span className="env-status-badge danger">● {probe.message}</span>
|
||||||
● 使用系统解释器: <code>{probe.display_path}</code>
|
|
||||||
</span>
|
|
||||||
<span className="env-detector-hint">{probe.message}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,13 +116,6 @@ export const taskFormSchema = z
|
|||||||
message: '请输入工作目录',
|
message: '请输入工作目录',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!values.script.interpreter.trim()) {
|
|
||||||
context.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
path: ['script', 'interpreter'],
|
|
||||||
message: '请输入解释器或可执行程序',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!values.script.script.trim()) {
|
if (!values.script.script.trim()) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
@@ -171,7 +164,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
|||||||
script: {
|
script: {
|
||||||
working_dir: '',
|
working_dir: '',
|
||||||
venv: '',
|
venv: '',
|
||||||
interpreter: 'python.exe',
|
interpreter: '',
|
||||||
script: 'main.py',
|
script: 'main.py',
|
||||||
args: [],
|
args: [],
|
||||||
env: [],
|
env: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user