Compare commits

...
3 Commits
Author SHA1 Message Date
CNWei 8fdc55e17d feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构
- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320
- 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边
- uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页
- 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量
- 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*,
  运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
2026-11-08 21:26:47 +08:00
CNWei 37479f48c7 docs: 更新 uv 与虚拟环境文档
- README:三栏工作台与弹窗说明、uv 路径默认值(python / uv-cache)、镜像只在装包时生效、API 与数据布局、测试现状注记

- RELEASE:补 uv-cache 与 uv 目录、配置入口改为弹窗、运行时注入说明

- 新增 ADR-0009:uv 与托管 Python 环境的决策与边界

- PLAN:补记已完成的后继迭代
2026-11-07 17:05:02 +08:00
CNWei 1914d38451 feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验

- uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程

- 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包

- 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用

- 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗

- 发布包构建后去掉工作区继承的低完整性标签

- 修复调度集成测试并发时误取 skipped 运行的问题
2026-11-07 16:57:27 +08:00
50 changed files with 6715 additions and 940 deletions
+11 -2
View File
@@ -146,8 +146,6 @@ Task
├── timezone 默认使用系统时区,可选择 IANA 时区
├── enabled 是否参与自动调度
├── timeout_secs 单次执行超时
├── concurrency_policy 首版固定为 skip
├── retry_policy 首版允许 0 次重试,结构上预留
├── action 带 type 标签的任务动作
├── created_at
└── updated_at
@@ -462,3 +460,14 @@ cargo test --manifest-path backend/Cargo.toml
4. 任务模板、导入导出和批量操作。
5. 运行指标、告警和长期监控。
6. HTTP 响应断言、认证方案和请求重放。
## 13. 已完成的后继迭代
计划阶段之外已经交付、并已写入 ADR 的改动:
- **本地 uv 管理**([ADR-0009](docs/decisions/0009-uv-and-managed-environments.md)):uv 只用程序目录副本或指定路径,
不读系统 PATH;可从科大镜像下载并校验 sha256;`UV_*` 环境变量可配置,两个路径变量默认相对程序目录(`python` / `uv-cache`)。
- **托管虚拟环境**:`data/envs/<名称>`、uv 下载的 Python 版本管理、预装包与已装包查看。
- **环境时机调整**:新建任务保存时用 uv 创建 `.venv`,运行期只校验;运行任务会注入平台 uv、`UV_*` 与默认 `UV_DEFAULT_INDEX`。
- **界面**:顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗(uv 与镜像源不再同页)。
+101 -39
View File
@@ -50,6 +50,9 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
release/TaskPilot/
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
├── web/ # 前端静态资源(字体可选)
├── uv/ # 程序下载/自备的 uv.exe 放这里(uv 配置弹窗可切换来源)
├── python/ # uv 下载的 Python 解释器(UV_PYTHON_INSTALL_DIR 默认相对值为 python)
├── uv-cache/ # uv 缓存(UV_CACHE_DIR 默认相对值为 uv-cache,运行后才会出现)
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
├── logs/{task_id}/{run_id}.log
├── start.ps1
@@ -64,7 +67,7 @@ npm --prefix ..\stash-ui install
npm --prefix ..\stash-ui run build
pnpm --dir frontend install
cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000
cargo run --manifest-path backend/Cargo.toml --bin task-pilot-backend # 终端一:API,127.0.0.1:3000
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
```
@@ -111,14 +114,14 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
| 分组 | 能力 |
| --- | --- |
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选、分页 |
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选 |
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
| 界面 | 左侧导航(任务调度 / Python 镜像)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
@@ -132,12 +135,11 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
| --- | --- |
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
| `name` | 展示名,1–120 字符 |
| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 |
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
| `enabled` | 是否注册调度 |
| `timeout_secs` | 1–86400;超时终止整个进程树 |
| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) |
| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** |
| `action` | 见下 |
### 三类 Action
@@ -184,7 +186,6 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
- **重试**:字段已持久化但执行器尚未实现自动重试(见「已知限制」)。
---
@@ -220,14 +221,32 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
**没有虚拟环境时**:
- 工作目录根部有 `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 任务不再静默回退到系统解释器。
- 先看用户自己的环境:任务里指定的 `venv`、解释器路径、工作目录向上查找,命中就直接用,不重复创建。
- 都没有时,**新建任务时会用 uv 自动创建 `.venv`**:`pyproject.toml` 走 `uv sync`,
仅有 `requirements*.txt` 走 `uv venv .venv` + `uv pip install -r`,纯标准库脚本(没有依赖文件)建一个空环境。
保存按钮会显示「正在创建虚拟环境…」,环境问题当场暴露。手动路径是编辑器里的「用 uv 创建环境」按钮。
- 运行期只做校验、不再自动装依赖:没有 `.venv` 的 Python 任务会直接失败并提示先创建环境。
- 创建时会使用当前默认 PyPI 镜像:注入 `UV_DEFAULT_INDEX`(实际生效的是它)以及兼容用的 `UV_INDEX_URL` / `PIP_INDEX_URL`,不会绕过镜像设置。
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
没有 uv 时在 uv 配置弹窗点「下载 uv」,从「下载镜像」(默认科大
`mirrors.ustc.edu.cn/github-release`)下载当前架构的压缩包,
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去(弹窗状态条里会显示 uv 与解释器的下载地址);
本机已经装了 uv 的话,把它填进「指定路径」即可。
- **uv 的下载镜像不跟 PyPI 包索引混用**:它配在 uv 配置弹窗的「下载镜像」里(GitHub Release
镜像根),uv 本体与 Python 解释器都从它派生(uv 地址 = `镜像根/astral-sh/uv/LatestRelease`,
解释器地址 = `镜像根/astral-sh/python-build-standalone/releases/download`)。
解释器这一步也可以单独覆盖:环境变量里就有一条 `UV_PYTHON_INSTALL_MIRROR`(默认跟随镜像根,
改它的值即可换成别的发行源),它和 `UV_PYTHON_INSTALL_DIR`、`UV_CACHE_DIR` 一样是固定项,
只能改值、不能删除。
- uv 配置弹窗里还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、`UV_HTTP_TIMEOUT`),
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的
`python` / `uv-cache`(随包移动),也可以改成绝对路径。
- Python 解释器的下载与查看也在 uv 配置弹窗里(`uv python install` / `uv python list`)——
解释器属于 uv,只是建虚拟环境时要用;虚拟环境弹窗只从这份已下载列表里挑版本。
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。
- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、弹窗里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX`
(任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。
- 解释器由语言自动决定,界面不再显示解释器输入框:
Python 始终使用虚拟环境里的 Python,Shell 使用 `cmd.exe`,Node 使用 `node.exe`,Lua 使用 `lua.exe`。
自定义解释器仍可通过 API 的 `action.interpreter` 字段传入。
@@ -248,21 +267,33 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
## Web 界面
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
页面级导航已去掉,配置都收到任务调度页的卡片里。
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器由语言自动选择,界面不再要求手填。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
(任务自身配置的环境变量优先),不会自动安装依赖。
- **uv 配置弹窗**:顶部状态条(版本 / 可执行文件 / uv 下载与解释器镜像地址 / 解释器与环境变量数量)+ 按配置顺序排的三段。
① **uv 可执行文件**:单选「程序目录 uv / 指定路径」——程序目录只展示路径、不可编辑,缺失时才出现「下载 uv」;
指定路径是可编辑输入框,右侧「浏览」(页面内文件浏览器挑 `uv.exe`,保存时校验能否运行);下面是「下载镜像」
(GitHub Release 镜像根,uv 与解释器共用);② **环境变量**:`UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` 决定目录,
`UV_PYTHON_INSTALL_MIRROR` 决定解释器下载源,这三项只能改值、不能删除(清空或重复会拦下保存),
其余 `UV_*` 变量可增删;③ **Python 解释器**:按上面配置的目录列出已下载版本,填版本号即下载。
底部关闭 / 保存设置;uv 来源不读系统 PATH。
- **镜像源弹窗**:只放 PyPI 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;
可新增、编辑、启用停用、设为默认。包索引只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
uv 自己的下载镜像不在这里,在 uv 配置弹窗里。
- **虚拟环境弹窗**:只负责**使用**已下载的解释器创建托管环境
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包);解释器下载入口在 uv 配置弹窗。
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
业务块(侧栏、任务表单元格、日志控制台、表单栅格)。
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
`tools/build-fonts.py` 生成,缺失时回退系统字体。
@@ -315,7 +346,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
| `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=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 |
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
@@ -330,6 +361,25 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 |
| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) |
### uv
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 |
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 |
| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
### 托管虚拟环境
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/python/pythons` | uv 下载到平台目录(`UV_PYTHON_INSTALL_DIR`)的解释器(版本、路径) |
| `POST` | `/api/v1/python/pythons` | 用 uv 下载指定 Python 版本(`version`,如 `3.12`),返回最新列表 |
| `GET` | `/api/v1/python/envs` | 列出 `data/envs` 下的环境(名称、绝对路径、`pyvenv.cfg` 里的 Python 版本) |
| `POST` | `/api/v1/python/envs` | 用 uv 创建环境(`name`、可选 `python_version`、可选 `packages` 预装) |
| `GET` | `/api/v1/python/envs/{name}/packages` | 已安装的包(`uv pip list --format json`) |
| `DELETE` | `/api/v1/python/envs/{name}` | 删除整个环境目录 |
### 任务与运行
| 方法 | 路径 | 说明 |
@@ -361,6 +411,7 @@ Content-Type: application/json
{
"id": "sync_data",
"name": "数据同步",
"description": "把上游数据同步到本地仓库,失败时记录日志",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
@@ -411,7 +462,7 @@ Content-Type: application/json
| `cron` | 可解析且存在未来触发点 |
| `timezone` | 有效 IANA 名称 |
| `timeout_secs` | 1–86400 |
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
| `description` | ≤500 字符,可留空 |
| `action.working_dir` / `script` | 非空 |
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
@@ -435,13 +486,18 @@ Content-Type: application/json
```text
data/task_pilot.db # SQLite(WAL)
data/envs/<名称>/ # 托管虚拟环境(「虚拟环境」弹窗创建)
logs/{task_id}/{run_id}.log # UTF-8 运行日志
uv/uv.exe # 平台 uv(下载或自备)
python/<版本>/ # uv 下载的 Python 解释器
uv-cache/ # uv 缓存(首次使用后出现)
.tmp/ # check.ps1 的测试临时目录(已 gitignore)
```
`tasks` 表:主键 `id`,索引 `updated_at`、`enabled`、`action_type`。
`task_runs` 表:`task_id` 外键 `ON DELETE CASCADE`,`status`/`trigger_type` 带 CHECK 约束,
索引 `(task_id, created_at DESC)` 与 `status`。
`uv_settings` 表:单行配置(`source`、`custom_path`、`extra_env` JSON)。
---
@@ -464,9 +520,11 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| `scheduler.rs` | 每任务一个调度循环、下次触发时间、注册与取消 |
| `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 |
| `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) |
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 |
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入、用 uv 建环境 |
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
@@ -475,13 +533,14 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| 模块 | 职责 |
| --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 |
| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) |
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 |
| `components/AppHeader.tsx` | 顶部品牌栏 |
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务卡片、uv/镜像/环境卡片与三个配置弹窗 |
| `components/UvSettingsModal.tsx` | uv 弹窗:状态条、来源选择、下载镜像、Python 解释器下载与列表、`UV_*` 环境变量 |
| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗:用已下载解释器创建环境、环境列表、已装包 |
| `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 |
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器(`mode="file"` 时为文件选择器,用于挑 `uv.exe`) |
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 |
| `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) |
@@ -547,9 +606,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py
| 层 | 覆盖 |
| --- | --- |
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 |
| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Rust 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
@@ -573,12 +632,14 @@ uv run --with fonttools --with brotli tools/build-fonts.py
## 已知限制
- 自动重试尚未实现:`retry_policy` 只校验与存储。
- 并发策略只有 `skip`;没有全局并发上限与队列。
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
- Conda 环境不会自动激活,需要显式填写解释器路径。
- 用 uv 创建环境是编辑器里的显式操作;运行中的任务不会在后台自动安装依赖。首次创建需要网络与可写的 uv 缓存目录。
- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。
- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。
- 默认缓存与 Python 目录在程序目录下(`uv-cache` / `python`),删除发布目录等于丢掉缓存和解释器;想复用全局目录就把 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR` 改成绝对路径。
- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
- 错过的调度不会补跑。
@@ -596,6 +657,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py
- [ADR-0006:视觉风格对齐主题并自托管字体](docs/decisions/0006-visual-theme-and-offline-fonts.md)
- [ADR-0007:运行期可靠性优化](docs/decisions/0007-runtime-reliability.md)
- [ADR-0008:日志合并推送与虚拟滚动](docs/decisions/0008-log-streaming-and-virtualization.md)
- [ADR-0009:uv 与托管 Python 环境](docs/decisions/0009-uv-and-managed-environments.md)
## 开发计划
+87
View File
@@ -263,6 +263,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -318,6 +324,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
[[package]]
name = "croner"
version = "4.0.1"
@@ -455,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
@@ -553,6 +569,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
dependencies = [
"zlib-rs",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -1234,6 +1259,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1835,6 +1872,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
@@ -2172,6 +2215,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.11.1",
"sqlx",
"tempfile",
"thiserror",
@@ -2187,6 +2231,7 @@ dependencies = [
"utoipa-axum",
"uuid",
"windows-sys 0.61.2",
"zip",
]
[[package]]
@@ -2450,6 +2495,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2947,8 +2998,44 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "zip"
version = "9.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f17fbc870bfac28c6228aed7d6e6df352e26bf7015c4313727e16a7a01ce5e"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"libc",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+2
View File
@@ -14,6 +14,7 @@ futures-util = { version = "0.3.34", features = ["std"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
thiserror = "2.0.21"
tokio = { version = "1.53.3", features = ["full"] }
@@ -27,6 +28,7 @@ utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.3.0"
uuid = { version = "1.28.0", features = ["v4"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] }
zip = { version = "9.4.1", default-features = false, features = ["deflate"] }
[dev-dependencies]
http-body-util = "0.1.5"
-28
View File
@@ -1,28 +0,0 @@
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
CHECK (concurrency_policy = 'skip'),
retry_max_attempts INTEGER NOT NULL DEFAULT 0
CHECK (retry_max_attempts >= 0),
retry_delay_secs INTEGER NOT NULL DEFAULT 0
CHECK (retry_delay_secs >= 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
+127
View File
@@ -0,0 +1,127 @@
-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。
-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。
CREATE TABLE IF NOT EXISTS uv_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
custom_path TEXT,
extra_env TEXT NOT NULL DEFAULT '{}',
mirror_url TEXT,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at)
VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
@@ -1,62 +0,0 @@
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
+45 -11
View File
@@ -21,6 +21,11 @@ use crate::{
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
VenvProbeStatus, VenvSource,
},
python_envs::{
__path_create_env, __path_delete_env, __path_install_python, __path_list_env_packages,
__path_list_envs, __path_list_pythons, CreateManagedEnvRequest, InstallPythonRequest,
ManagedEnvPackage, ManagedEnvSummary, ManagedPython,
},
python_mirrors::{
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
routes::{
@@ -40,15 +45,18 @@ use crate::{
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
TaskStatsResponse, UpdateTaskRequest,
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
},
routes::{
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
__path_get_task, __path_list_tasks, __path_update_task,
},
},
uv::{
__path_download_uv, __path_get_uv_settings, __path_update_uv_settings,
UpdateUvSettingsRequest, UvCandidate, UvSettingsResponse, UvSource,
},
};
#[derive(Debug, Serialize, ToSchema)]
@@ -137,9 +145,11 @@ pub async fn preview_cron(
)
)]
pub async fn probe_python_venv(
State(state): State<AppState>,
ApiJson(request): ApiJson<VenvProbeRequest>,
) -> ApiResult<Json<VenvProbeResponse>> {
tokio::task::spawn_blocking(move || python_env::probe_venv(&request))
let uv = crate::uv::current(&state.pool).await.program();
tokio::task::spawn_blocking(move || python_env::probe_venv(&request, &uv))
.await
.map(Json)
.map_err(ApiError::internal)
@@ -165,10 +175,16 @@ pub async fn create_python_venv(
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))
let uv = crate::uv::current(&state.pool).await;
python_env::create_project_venv(
&request.working_dir,
mirror.as_ref(),
&uv.program(),
&uv.env(),
)
.await
.map(Json)
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
}
#[utoipa::path(
@@ -177,7 +193,7 @@ pub async fn create_python_venv(
tag = "system",
params(ListDirectoriesQuery),
responses(
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
(status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse),
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
)
)]
@@ -255,12 +271,21 @@ pub async fn list_schedule(
preview_cron,
probe_python_venv,
create_python_venv,
get_uv_settings,
update_uv_settings,
download_uv,
list_python_mirrors,
get_python_mirror,
create_python_mirror,
update_python_mirror,
delete_python_mirror,
set_default_python_mirror,
list_envs,
create_env,
list_env_packages,
delete_env,
list_pythons,
install_python,
list_directories,
task_stats,
list_schedule,
@@ -293,6 +318,15 @@ pub async fn list_schedule(
PythonMirrorResponse,
CreatePythonMirrorRequest,
UpdatePythonMirrorRequest,
UvSettingsResponse,
UpdateUvSettingsRequest,
UvCandidate,
UvSource,
ManagedEnvSummary,
ManagedEnvPackage,
CreateManagedEnvRequest,
ManagedPython,
InstallPythonRequest,
DirectoryEntry,
DirectoryListingResponse,
TaskStatsResponse,
@@ -301,8 +335,6 @@ pub async fn list_schedule(
TaskActionType,
ScriptLanguage,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
@@ -357,6 +389,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router())
.merge(crate::python_mirrors::routes::router())
.merge(crate::python_envs::router())
.merge(crate::uv::router())
.merge(crate::runs::routes::router())
.route("/api/{*path}", any(api_not_found))
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
+46 -2
View File
@@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500;
pub struct ListDirectoriesQuery {
/// 绝对目录路径;留空表示列出盘符
pub path: Option<String>,
/// 是否一并返回文件(挑选可执行文件时用);默认只返回目录
#[serde(default)]
pub include_files: Option<bool>,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -22,6 +25,8 @@ pub struct DirectoryEntry {
pub name: String,
/// 绝对路径,选中后回填到表单
pub path: String,
/// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)
pub is_dir: bool,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -79,6 +84,7 @@ pub fn list_directories(
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
})?;
let include_files = query.include_files.unwrap_or(false);
let mut entries = Vec::new();
let mut truncated = false;
for entry in reader.flatten() {
@@ -87,19 +93,26 @@ pub fn list_directories(
.file_type()
.map(|kind| kind.is_dir())
.unwrap_or_else(|_| entry_path.is_dir());
if !is_directory {
if !is_directory && !include_files {
continue;
}
entries.push(DirectoryEntry {
name: entry.file_name().to_string_lossy().into_owned(),
path: entry_path.to_string_lossy().into_owned(),
is_dir: is_directory,
});
if entries.len() >= MAX_ENTRIES {
truncated = true;
break;
}
}
entries.sort_by_key(|entry| entry.name.to_lowercase());
// 目录在前、文件在后,各自按名称排序:挑选文件时不用在一堆文件里找文件夹。
entries.sort_by(|left, right| {
right
.is_dir
.cmp(&left.is_dir)
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
});
Ok(DirectoryListingResponse {
path: Some(directory.to_string_lossy().into_owned()),
@@ -126,6 +139,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
DirectoryEntry {
name: format!("{letter}:\\"),
path: format!("{letter}:\\"),
is_dir: true,
}
})
.collect()
@@ -136,6 +150,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
vec![DirectoryEntry {
name: "/".to_owned(),
path: "/".to_owned(),
is_dir: true,
}]
}
@@ -146,6 +161,14 @@ mod tests {
fn query(path: Option<&str>) -> ListDirectoriesQuery {
ListDirectoriesQuery {
path: path.map(str::to_owned),
include_files: None,
}
}
fn query_with_files(path: Option<&str>) -> ListDirectoriesQuery {
ListDirectoriesQuery {
path: path.map(str::to_owned),
include_files: Some(true),
}
}
@@ -164,6 +187,7 @@ mod tests {
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["Alpha", "zeta"]);
assert!(listing.entries.iter().all(|entry| entry.is_dir));
assert_eq!(
listing.parent.as_deref(),
Some(temp.path().parent().unwrap().to_str().unwrap())
@@ -171,6 +195,26 @@ mod tests {
assert!(!listing.truncated);
}
#[test]
fn lists_files_when_requested_after_directories() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("bin")).unwrap();
fs::write(temp.path().join("zeta.exe"), "x").unwrap();
fs::write(temp.path().join("Alpha.txt"), "x").unwrap();
let listing =
list_directories(&query_with_files(Some(temp.path().to_str().unwrap()))).unwrap();
let names = listing
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["bin", "Alpha.txt", "zeta.exe"]);
assert!(listing.entries[0].is_dir);
assert!(listing.entries[1..].iter().all(|entry| !entry.is_dir));
}
#[test]
fn returns_absolute_paths_for_entries() {
let temp = tempfile::TempDir::new().unwrap();
+10
View File
@@ -119,6 +119,16 @@ impl ApiError {
}
}
pub fn env_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "ENV_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn mirror_conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
+2
View File
@@ -5,12 +5,14 @@ pub mod directory_browser;
pub mod error;
pub mod process_job;
pub mod python_env;
pub mod python_envs;
pub mod python_mirrors;
pub mod runs;
pub mod scheduler;
pub mod security;
pub mod state;
pub mod tasks;
pub mod uv;
use axum::Router;
use sqlx::SqlitePool;
+81 -44
View File
@@ -33,6 +33,28 @@ impl PreparedPython {
}
command.envs(&self.environment);
}
/// 把 `directory` 插到 PATH 最前面,让任务脚本里的 `uv` 用平台副本。
pub fn prepend_path(&mut self, directory: &Path) -> io::Result<()> {
let base = self
.environment
.get("PATH")
.map(OsString::from)
.or_else(|| env::var_os("PATH"))
.unwrap_or_default();
let mut seen = std::collections::HashSet::new();
let mut paths = Vec::new();
for path in std::iter::once(directory.to_path_buf()).chain(env::split_paths(&base)) {
let key = path.to_string_lossy().to_ascii_lowercase();
if seen.insert(key) {
paths.push(path);
}
}
let joined = env::join_paths(paths).map_err(io::Error::other)?;
self.environment
.insert("PATH".to_owned(), joined.to_string_lossy().into_owned());
Ok(())
}
}
pub fn prepare_script_environment(
@@ -244,7 +266,7 @@ pub struct VenvProbeResponse {
pub has_requirements: bool,
/// 探测到的 requirements 文件名(仅根目录)
pub requirements_files: Vec<String>,
/// 没有虚拟环境时,是否具备用 uv 创建的条件
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
pub can_create: bool,
}
@@ -255,7 +277,7 @@ pub struct CreatePythonVenvRequest {
pub working_dir: String,
}
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
let working_dir = request.working_dir.trim();
let interpreter = {
let trimmed = request.interpreter.trim();
@@ -271,7 +293,7 @@ pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
.map(str::trim)
.filter(|value| !value.is_empty());
let interpreter_kind = classify_interpreter(interpreter);
let uv = uv_available();
let uv = crate::uv::probe_version(uv).is_some();
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
if working_dir.is_empty() {
@@ -367,16 +389,12 @@ fn finalize_probe(
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);
response.can_create = response.status == VenvProbeStatus::Missing && uv_available;
if response.status == VenvProbeStatus::Missing {
response.message = if response.can_create {
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
} else if !uv_available {
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
} else {
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
};
}
response
@@ -426,25 +444,12 @@ pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
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>,
uv: &Path,
uv_env: &BTreeMap<String, String>,
) -> Result<VenvProbeResponse, String> {
let working_directory =
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
@@ -457,25 +462,23 @@ pub async fn create_project_venv(
interpreter: "python.exe".to_owned(),
venv: None,
};
let existing = probe_venv(&probe_request);
let existing = probe_venv(&probe_request, uv);
if existing.status == VenvProbeStatus::Linked {
return Ok(existing);
}
let files = scan_project_files(working_dir);
if !files.has_pyproject && files.requirements.is_empty() {
if crate::uv::probe_version(uv).is_none() {
return Err(
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
);
}
if !uv_available() {
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
}
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
if files.has_pyproject {
run_uv(&working_directory, &["sync"], mirror).await?;
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
} else {
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
let python = working_directory
.join(".venv")
.join("Scripts")
@@ -486,12 +489,14 @@ pub async fn create_project_venv(
&working_directory,
&["pip", "install", "--python", &python, "-r", requirement],
mirror,
uv,
uv_env,
)
.await?;
}
}
let probe = probe_venv(&probe_request);
let probe = probe_venv(&probe_request, uv);
if probe.status != VenvProbeStatus::Linked {
return Err(format!(
"uv 执行完成,但没有找到有效的 .venv:{}",
@@ -501,12 +506,14 @@ pub async fn create_project_venv(
Ok(probe)
}
async fn run_uv(
pub(crate) async fn run_uv(
working_dir: &Path,
args: &[&str],
mirror: Option<&DefaultPythonMirror>,
uv: &Path,
uv_env: &BTreeMap<String, String>,
) -> Result<String, String> {
let mut command = Command::new("uv");
let mut command = Command::new(uv);
command
.args(args)
.current_dir(working_dir)
@@ -526,6 +533,8 @@ async fn run_uv(
command.env("PIP_TRUSTED_HOST", host);
}
}
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
command.envs(uv_env);
#[cfg(windows)]
command.creation_flags(0x0800_0000);
@@ -812,6 +821,30 @@ mod tests {
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn prepends_the_uv_directory_to_path() {
let temp = TempDir::new().unwrap();
let mut prepared = prepare_script_environment(
temp.path().to_str().unwrap(),
"python.exe",
None,
&BTreeMap::new(),
)
.unwrap();
let uv_directory = temp.path().join("uv");
prepared.prepend_path(&uv_directory).unwrap();
let paths: Vec<_> = env::split_paths(&prepared.environment["PATH"]).collect();
assert_eq!(paths.first(), Some(&uv_directory));
// 重复插入要去重
prepared.prepend_path(&uv_directory).unwrap();
assert_eq!(
env::split_paths(&prepared.environment["PATH"]).count(),
paths.len()
);
}
#[test]
fn non_python_interpreter_is_not_modified() {
let temp = TempDir::new().unwrap();
@@ -934,11 +967,14 @@ mod tests {
}
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
probe_venv(&VenvProbeRequest {
working_dir: working_dir.to_owned(),
interpreter: interpreter.to_owned(),
venv: venv.map(str::to_owned),
})
probe_venv(
&VenvProbeRequest {
working_dir: working_dir.to_owned(),
interpreter: interpreter.to_owned(),
venv: venv.map(str::to_owned),
},
Path::new("uv"),
)
}
#[test]
@@ -985,7 +1021,7 @@ mod tests {
assert_eq!(response.status, VenvProbeStatus::Missing);
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
assert!(response.venv_path.is_none());
assert!(!response.can_create);
assert_eq!(response.can_create, response.uv_available);
}
#[test]
@@ -1004,12 +1040,13 @@ mod tests {
}
#[test]
fn probe_does_not_offer_uv_without_dependency_files() {
fn probe_offers_uv_creation_even_without_dependency_files() {
let temp = TempDir::new().unwrap();
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
assert!(!response.can_create);
// 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。
assert_eq!(response.can_create, response.uv_available);
}
#[test]
+515
View File
@@ -0,0 +1,515 @@
use std::{
fs,
path::{Path, PathBuf},
};
use axum::{
Json, Router,
extract::{Path as AxumPath, State},
http::StatusCode,
routing::{delete, get},
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{
error::{ApiError, ApiJson, ApiResult},
python_env::run_uv,
state::AppState,
};
/// 预创建的虚拟环境放在 `data/envs` 下,按目录名管理。
const ENV_ROOT: &str = "data/envs";
const MAX_NAME_CHARS: usize = 64;
const MAX_PACKAGES: usize = 50;
const MAX_PACKAGE_CHARS: usize = 200;
#[derive(Debug, Serialize, ToSchema)]
pub struct ManagedEnvSummary {
pub name: String,
/// 虚拟环境根目录的绝对路径
pub path: String,
/// 取自 `pyvenv.cfg` 的 `version_info`
pub python_version: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ManagedEnvPackage {
pub name: String,
pub version: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreateManagedEnvRequest {
/// 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识
pub name: String,
/// 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定
#[serde(default)]
pub python_version: Option<String>,
/// 预装包,每行一个,例如 `numpy==2.1.0`
#[serde(default)]
pub packages: Vec<String>,
}
fn root() -> PathBuf {
std::path::absolute(Path::new(ENV_ROOT)).unwrap_or_else(|_| PathBuf::from(ENV_ROOT))
}
fn validate_name(raw: &str) -> ApiResult<String> {
let name = raw.trim();
let valid = !name.is_empty()
&& name.chars().count() <= MAX_NAME_CHARS
&& !name.starts_with('.')
&& name.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
});
if !valid {
return Err(ApiError::validation(
"name",
"环境名只能包含字母、数字、点、下划线、连字符,且不能以点开头",
));
}
Ok(name.to_owned())
}
fn validate_python_version(raw: Option<&str>) -> ApiResult<Option<String>> {
let Some(value) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > 16
|| !value
.chars()
.all(|character| character.is_ascii_digit() || character == '.')
{
return Err(ApiError::validation(
"python_version",
"Python 版本只填数字和点,例如 3.11 或 3.12.1",
));
}
Ok(Some(value.to_owned()))
}
fn validate_packages(packages: &[String]) -> ApiResult<Vec<String>> {
if packages.len() > MAX_PACKAGES {
return Err(ApiError::validation(
"packages",
format!("一次最多预装 {MAX_PACKAGES} 个包"),
));
}
let mut cleaned = Vec::with_capacity(packages.len());
for package in packages {
let package = package.trim();
if package.is_empty() {
continue;
}
// 以 - 开头会被 uv 当成参数,直接拒绝。
if package.starts_with('-') {
return Err(ApiError::validation(
"packages",
format!("包名不能以 - 开头: {package}"),
));
}
if package.chars().count() > MAX_PACKAGE_CHARS
|| package.chars().any(|character| character.is_control())
{
return Err(ApiError::validation(
"packages",
format!("包名过长或包含非法字符: {package}"),
));
}
cleaned.push(package.to_owned());
}
Ok(cleaned)
}
fn read_summary(name: &str, path: &Path) -> Option<ManagedEnvSummary> {
let python = path.join("Scripts").join("python.exe");
let config = path.join("pyvenv.cfg");
if !python.is_file() || !config.is_file() {
return None;
}
let python_version = fs::read_to_string(&config).ok().and_then(|text| {
text.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "version_info").then(|| value.trim().to_owned())
})
});
Some(ManagedEnvSummary {
name: name.to_owned(),
path: path.to_string_lossy().into_owned(),
python_version,
})
}
fn env_path(name: &str) -> ApiResult<PathBuf> {
let path = root().join(name);
if !path.is_dir() {
return Err(ApiError::env_not_found(format!("虚拟环境 {name} 不存在")));
}
Ok(path)
}
#[utoipa::path(
get,
path = "/api/v1/python/envs",
tag = "python-envs",
responses(
(status = 200, description = "Managed virtual environments", body = Vec<ManagedEnvSummary>)
)
)]
pub async fn list_envs() -> ApiResult<Json<Vec<ManagedEnvSummary>>> {
let directory = root();
let mut envs = Vec::new();
if let Ok(entries) = fs::read_dir(&directory) {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(summary) = read_summary(&name, &entry.path()) {
envs.push(summary);
}
}
}
envs.sort_by_key(|env| env.name.to_lowercase());
Ok(Json(envs))
}
#[utoipa::path(
post,
path = "/api/v1/python/envs",
tag = "python-envs",
request_body = CreateManagedEnvRequest,
responses(
(status = 201, description = "Virtual environment created", body = ManagedEnvSummary),
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
)
)]
pub async fn create_env(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreateManagedEnvRequest>,
) -> ApiResult<(StatusCode, Json<ManagedEnvSummary>)> {
let name = validate_name(&request.name)?;
let python_version = validate_python_version(request.python_version.as_deref())?;
let packages = validate_packages(&request.packages)?;
let directory = root();
let target = directory.join(&name);
if target.exists() {
return Err(ApiError::bad_request(
"ENV_EXISTS",
format!("虚拟环境 {name} 已存在,先删除再重建"),
));
}
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
let uv = crate::uv::current(&state.pool).await;
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
.await
.unwrap_or_else(|error| {
tracing::warn!(%error, "读取默认 Python 镜像失败,创建环境时改用 uv 自身配置");
None
});
let program = uv.program();
if crate::uv::probe_version(&program).is_none() {
return Err(ApiError::bad_request(
"UV_UNAVAILABLE",
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
));
}
let target_arg = target.to_string_lossy().into_owned();
let mut venv_args = vec!["venv".to_owned(), target_arg.clone()];
if let Some(version) = python_version {
venv_args.push("--python".to_owned());
venv_args.push(version);
}
if let Err(message) = run_uv(&directory, &owned(&venv_args), None, &program, &uv.env()).await {
let _ = fs::remove_dir_all(&target);
return Err(ApiError::bad_request(
"ENV_CREATE_FAILED",
format!("创建虚拟环境失败: {message}"),
));
}
if !packages.is_empty() {
let python = target.join("Scripts").join("python.exe");
let mut install_args = vec![
"pip".to_owned(),
"install".to_owned(),
"--python".to_owned(),
python.to_string_lossy().into_owned(),
];
install_args.extend(packages.clone());
if let Err(message) = run_uv(
&target,
&owned(&install_args),
mirror.as_ref(),
&program,
&uv.env(),
)
.await
{
let _ = fs::remove_dir_all(&target);
return Err(ApiError::bad_request(
"ENV_CREATE_FAILED",
format!("安装预装包失败: {message}"),
));
}
}
let summary = read_summary(&name, &target).ok_or_else(|| {
ApiError::bad_request("ENV_CREATE_FAILED", "环境创建后没有找到 python.exe")
})?;
Ok((StatusCode::CREATED, Json(summary)))
}
#[utoipa::path(
get,
path = "/api/v1/python/envs/{name}/packages",
tag = "python-envs",
params(("name" = String, Path, description = "Environment name")),
responses(
(status = 200, description = "Installed packages", body = Vec<ManagedEnvPackage>),
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
)
)]
pub async fn list_env_packages(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> ApiResult<Json<Vec<ManagedEnvPackage>>> {
let name = validate_name(&name)?;
let path = env_path(&name)?;
let python = path.join("Scripts").join("python.exe");
let uv = crate::uv::current(&state.pool).await;
let program = uv.program();
if crate::uv::probe_version(&program).is_none() {
return Err(ApiError::bad_request(
"UV_UNAVAILABLE",
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
));
}
let arguments = [
"pip".to_owned(),
"list".to_owned(),
"--python".to_owned(),
python.to_string_lossy().into_owned(),
"--format".to_owned(),
"json".to_owned(),
];
let stdout = run_uv(&path, &owned(&arguments), None, &program, &uv.env())
.await
.map_err(|message| {
ApiError::bad_request(
"ENV_PACKAGES_FAILED",
format!("读取已安装包失败: {message}"),
)
})?;
let packages = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
.map_err(ApiError::internal)?
.into_iter()
.filter_map(|value| {
let name = value.get("name")?.as_str()?.to_owned();
let version = value
.get("version")?
.as_str()
.unwrap_or_default()
.to_owned();
Some(ManagedEnvPackage { name, version })
})
.collect();
Ok(Json(packages))
}
#[utoipa::path(
delete,
path = "/api/v1/python/envs/{name}",
tag = "python-envs",
params(("name" = String, Path, description = "Environment name")),
responses(
(status = 204, description = "Environment deleted"),
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_env(AxumPath(name): AxumPath<String>) -> ApiResult<StatusCode> {
let name = validate_name(&name)?;
let path = env_path(&name)?;
fs::remove_dir_all(&path).map_err(ApiError::internal)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ManagedPython {
pub version: String,
/// uv 下载到平台目录下的解释器路径
pub path: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct InstallPythonRequest {
/// 要下载的版本,例如 `3.12` 或 `3.12.4`
pub version: String,
}
fn version_key(version: &str) -> Vec<u32> {
version
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect()
}
/// 只返回 uv 下载到平台目录里的解释器(忽略系统里已有的 Python)。
async fn installed_pythons(pool: &sqlx::SqlitePool) -> Result<Vec<ManagedPython>, String> {
let uv = crate::uv::current(pool).await;
let program = uv.program();
if crate::uv::probe_version(&program).is_none() {
return Err("uv 不可用:请先在「Python 环境」页下载或指定 uv".to_owned());
}
let directory = uv.python_install_dir();
fs::create_dir_all(&directory).map_err(|error| format!("无法创建 Python 目录: {error}"))?;
let arguments = [
"python".to_owned(),
"list".to_owned(),
"--only-installed".to_owned(),
"--output-format".to_owned(),
"json".to_owned(),
];
let stdout = run_uv(&directory, &owned(&arguments), None, &program, &uv.env()).await?;
let prefix = directory.to_string_lossy().to_lowercase();
let mut pythons: Vec<ManagedPython> = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
.map_err(|error| format!("解析 uv python list 失败: {error}"))?
.into_iter()
.filter_map(|value| {
let version = value.get("version")?.as_str()?.to_owned();
// uv 按自己的 cwd 输出相对路径(这里的 cwd 就是平台 Python 目录),先补成绝对路径。
let path = Path::new(value.get("path")?.as_str()?);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
directory.join(path)
};
let path = absolute.to_string_lossy().into_owned();
path.to_lowercase()
.starts_with(&prefix)
.then_some(ManagedPython { version, path })
})
.collect();
pythons.sort_by_key(|python| version_key(&python.version));
pythons.dedup_by(|left, right| left.version == right.version);
Ok(pythons)
}
#[utoipa::path(
get,
path = "/api/v1/python/pythons",
tag = "python-envs",
responses(
(status = 200, description = "Pythons downloaded by uv into the platform directory", body = Vec<ManagedPython>),
(status = 400, description = "uv unavailable", body = crate::error::ErrorResponse)
)
)]
pub async fn list_pythons(State(state): State<AppState>) -> ApiResult<Json<Vec<ManagedPython>>> {
let pythons = installed_pythons(&state.pool)
.await
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
Ok(Json(pythons))
}
#[utoipa::path(
post,
path = "/api/v1/python/pythons",
tag = "python-envs",
request_body = InstallPythonRequest,
responses(
(status = 200, description = "Python downloaded; returns the installed list", body = Vec<ManagedPython>),
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
)
)]
pub async fn install_python(
State(state): State<AppState>,
ApiJson(request): ApiJson<InstallPythonRequest>,
) -> ApiResult<Json<Vec<ManagedPython>>> {
let version = validate_python_version(Some(&request.version))?
.ok_or_else(|| ApiError::validation("version", "请填写要下载的 Python 版本,例如 3.12"))?;
let uv = crate::uv::current(&state.pool).await;
let program = uv.program();
if crate::uv::probe_version(&program).is_none() {
return Err(ApiError::bad_request(
"UV_UNAVAILABLE",
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
));
}
let directory = uv.python_install_dir();
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
let arguments = ["python".to_owned(), "install".to_owned(), version.clone()];
run_uv(&directory, &owned(&arguments), None, &program, &uv.env())
.await
.map_err(|message| {
ApiError::bad_request(
"PYTHON_INSTALL_FAILED",
format!("下载 Python {version} 失败: {message}"),
)
})?;
let pythons = installed_pythons(&state.pool)
.await
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
Ok(Json(pythons))
}
fn owned(arguments: &[String]) -> Vec<&str> {
arguments.iter().map(String::as_str).collect()
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/python/envs", get(list_envs).post(create_env))
.route(
"/api/v1/python/envs/{name}/packages",
get(list_env_packages),
)
.route("/api/v1/python/envs/{name}", delete(delete_env))
.route(
"/api/v1/python/pythons",
get(list_pythons).post(install_python),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_safe_environment_names() {
assert_eq!(validate_name(" data-science ").unwrap(), "data-science");
assert_eq!(validate_name("py3.11_web").unwrap(), "py3.11_web");
}
#[test]
fn rejects_unsafe_environment_names() {
for name in ["", "..", ".hidden", "a/b", "a\\b", "a b", "环境"] {
assert!(validate_name(name).is_err(), "{name} should be rejected");
}
}
#[test]
fn validates_python_versions() {
assert_eq!(
validate_python_version(Some(" 3.11 ")).unwrap().as_deref(),
Some("3.11")
);
assert!(validate_python_version(Some("latest")).is_err());
assert!(validate_python_version(Some("3.11 --system")).is_err());
assert!(validate_python_version(None).unwrap().is_none());
}
#[test]
fn rejects_packages_that_look_like_flags() {
assert!(validate_packages(&["--index-url=http://x".to_owned()]).is_err());
assert!(validate_packages(&["numpy==2.1.0".to_owned()]).is_ok());
}
}
+36 -48
View File
@@ -437,7 +437,7 @@ impl TaskExecutor {
} => {
let script_language =
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
let prepared = match prepare_script_environment(
let mut prepared = match prepare_script_environment(
working_dir,
interpreter,
venv.as_deref(),
@@ -448,58 +448,46 @@ impl TaskExecutor {
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
}
};
let mut notes = Vec::new();
// 环境在保存任务时创建;运行期只做校验,避免跑到一半才开始装依赖。
if prepared.virtual_env.is_none() && script_language == ScriptLanguage::Python {
return failed_outcome(
"项目还没有 .venv;请在编辑器里用 uv 创建虚拟环境(新建任务时会自动创建)",
);
}
if let Some(venv) = &prepared.virtual_env {
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
}
// 平台已经不依赖系统 PATH 的 uv:把平台副本放进任务进程的 PATH,
// 脚本里直接写 `uv ...` 也能用,而且和平台是同一个版本。
let uv = crate::uv::current(&self.inner.pool).await;
if script_language == ScriptLanguage::Python
&& let Some(directory) = uv.program().parent()
&& let Err(error) = prepared.prepend_path(directory)
{
tracing::warn!(%error, "failed to prepend the managed uv directory to PATH");
}
let mut command = Command::new(&prepared.interpreter);
prepared.apply(&mut command);
let mut notes = Vec::new();
if let Some(venv) = &prepared.virtual_env {
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
} else if script_language == ScriptLanguage::Python {
// 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 或补充依赖文件",
);
}
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
// 任务自己配的环境变量优先。
if script_language == ScriptLanguage::Python {
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
{
Ok(Some(mirror)) => {
if !env_has(env, "PIP_INDEX_URL") {
command.env("PIP_INDEX_URL", &mirror.index_url);
}
if !env_has(env, "UV_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
.trusted_host
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
&& !env_has(env, "PIP_TRUSTED_HOST")
{
command.env("PIP_TRUSTED_HOST", host);
}
notes.push(format!(
"Python 包镜像: {} ({})",
mirror.name, mirror.index_url
));
for (key, value) in uv.env() {
if !env_has(env, &key) {
command.env(key, value);
}
Ok(None) => {}
Err(error) => {
tracing::warn!(
task_id = %task.id,
%error,
"failed to load default python mirror"
);
}
if let Ok(Some(mirror)) =
crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
{
for key in ["UV_DEFAULT_INDEX", "UV_INDEX_URL"] {
if !env_has(env, key) {
command.env(key, &mirror.index_url);
}
}
}
}
-2
View File
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
pub enum RunTrigger {
Schedule,
Manual,
Retry,
}
impl RunTrigger {
@@ -15,7 +14,6 @@ impl RunTrigger {
match self {
Self::Schedule => "schedule",
Self::Manual => "manual",
Self::Retry => "retry",
}
}
}
-1
View File
@@ -255,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
match value {
"schedule" => Ok(RunTrigger::Schedule),
"manual" => Ok(RunTrigger::Manual),
"retry" => Ok(RunTrigger::Retry),
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
}
}
+2 -3
View File
@@ -198,7 +198,7 @@ fn lock_next_runs(
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
use crate::tasks::model::{ScriptLanguage, TaskAction};
use super::*;
@@ -206,12 +206,11 @@ mod tests {
TaskResponse {
id: "schedule_test".to_owned(),
name: "Schedule test".to_owned(),
description: String::new(),
cron: cron.to_owned(),
timezone: timezone.to_owned(),
enabled: true,
timeout_secs: 30,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy::default(),
action: TaskAction::Script {
working_dir: ".".to_owned(),
venv: None,
+8 -26
View File
@@ -84,22 +84,6 @@ pub enum HttpMethod {
Post,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyPolicy {
#[default]
Skip,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_attempts: u32,
#[serde(default)]
pub delay_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskAction {
@@ -176,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
pub struct CreateTaskRequest {
pub id: String,
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -183,10 +170,6 @@ pub struct CreateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -194,6 +177,9 @@ pub struct CreateTaskRequest {
#[serde(deny_unknown_fields)]
pub struct UpdateTaskRequest {
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -201,10 +187,6 @@ pub struct UpdateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -212,12 +194,12 @@ pub struct UpdateTaskRequest {
pub struct TaskResponse {
pub id: String,
pub name: String,
/// 一句话说明
pub description: String,
pub cron: String,
pub timezone: String,
pub enabled: bool,
pub timeout_secs: u64,
pub concurrency_policy: ConcurrencyPolicy,
pub retry_policy: RetryPolicy,
pub action: TaskAction,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
+17 -40
View File
@@ -4,15 +4,14 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
";
#[derive(Debug, Clone, Copy, Default)]
@@ -59,13 +58,11 @@ pub struct ListFilters {
struct TaskRow {
id: String,
name: String,
description: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
@@ -76,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
@@ -94,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
Ok(Self {
id: row.id,
name: row.name,
description: row.description,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -115,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -149,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
description: request.description.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
@@ -164,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE id = ?",
)
@@ -186,19 +167,16 @@ pub async fn update(
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -269,9 +247,8 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE enabled = 1
ORDER BY updated_at DESC, id ASC",
+15 -17
View File
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_DESCRIPTION_LEN: usize = 500;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
@@ -26,17 +27,18 @@ const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_task_id(&request.id)?;
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -56,16 +58,17 @@ pub async fn update(
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -131,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
Ok(())
}
fn validate_description(description: &str) -> ApiResult<()> {
if description.chars().count() > MAX_DESCRIPTION_LEN {
return Err(crate::error::ApiError::validation(
"description",
format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"),
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
@@ -150,18 +162,6 @@ fn validate_common(
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
@@ -371,7 +371,6 @@ mod tests {
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
@@ -384,7 +383,6 @@ mod tests {
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
+894
View File
@@ -0,0 +1,894 @@
use std::{
collections::BTreeMap,
env,
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
use axum::{
Json, Router,
extract::State,
routing::{get, post},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use url::Url;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{
error::{ApiError, ApiJson, ApiResult},
state::AppState,
};
const UV_EXECUTABLE: &str = "uv.exe";
/// 自己下载的 uv 放在程序目录下的这个子目录里。
const UV_DIRECTORY: &str = "uv";
/// uv 下载的 Python 放在程序目录下的这个子目录里(相对路径默认值)。
const PYTHON_DIR_DEFAULT: &str = "python";
/// uv 的缓存目录默认值(相对程序目录)。
const CACHE_DIR_DEFAULT: &str = "uv-cache";
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR";
/// 内置的 uv 下载镜像根(中科大维护的 GitHub Release 镜像);可在「镜像源」里改成别的镜像。
const DEFAULT_MIRROR_BASE: &str = "https://mirrors.ustc.edu.cn/github-release";
/// 镜像根下 uv 自己的发布目录。
const UV_RELEASE_PATH: &str = "astral-sh/uv/LatestRelease";
/// 镜像根下 python-build-standalone 的发布目录(uv 的 `UV_PYTHON_INSTALL_MIRROR`)。
const PYTHON_RELEASE_PATH: &str = "astral-sh/python-build-standalone/releases/download";
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
const MAX_UV_ENV_ENTRIES: usize = 32;
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
const MAX_MIRROR_URL_CHARS: usize = 2048;
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
/// 会随启动方式变化,不适合当调度器的隐式依赖。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum UvSource {
/// 程序目录 `uv\uv.exe`:下载或自己放进来
Managed,
/// 用户指定路径的 uv
Custom,
}
impl UvSource {
const fn as_str(self) -> &'static str {
match self {
Self::Managed => "managed",
Self::Custom => "custom",
}
}
fn parse(value: &str) -> Option<Self> {
match value {
// 旧版本存过 system;现在统一当成程序目录 uv,由用户下载或指定。
"system" | "managed" => Some(Self::Managed),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct UvSettings {
pub source: UvSource,
pub custom_path: Option<String>,
pub extra_env: BTreeMap<String, String>,
/// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像
pub mirror_url: Option<String>,
}
impl Default for UvSettings {
fn default() -> Self {
Self {
source: UvSource::Managed,
custom_path: None,
extra_env: BTreeMap::new(),
mirror_url: None,
}
}
}
impl UvSettings {
/// 执行时真正调用的 uv 程序。
pub fn program(&self) -> PathBuf {
match self.source {
UvSource::Managed => managed_directory().join(UV_EXECUTABLE),
UvSource::Custom => self
.custom_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| managed_directory().join(UV_EXECUTABLE)),
}
}
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
/// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。
pub fn env(&self) -> BTreeMap<String, String> {
let mut env = self.extra_env.clone();
if !env
.keys()
.any(|key| key.eq_ignore_ascii_case(PYTHON_MIRROR_ENV))
{
env.insert(PYTHON_MIRROR_ENV.to_owned(), self.python_mirror_url());
}
for (key, default) in UV_PATH_DEFAULTS {
let value = env
.get(key)
.map(String::as_str)
.unwrap_or(default)
.trim()
.to_owned();
let resolved = resolve_platform_path(&value);
env.insert(key.to_owned(), resolved.to_string_lossy().into_owned());
}
env
}
/// uv 与 Python 解释器的下载镜像根(去掉结尾斜杠);没配就用内置默认。
pub fn mirror_base(&self) -> String {
self.mirror_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MIRROR_BASE)
.trim_end_matches('/')
.to_owned()
}
/// uv 自己那个压缩包的下载地址前缀。
pub fn uv_download_base(&self) -> String {
format!("{}/{}", self.mirror_base(), UV_RELEASE_PATH)
}
/// `UV_PYTHON_INSTALL_MIRROR`:uv 下载 Python 解释器的地址前缀。
pub fn python_mirror_url(&self) -> String {
format!("{}/{}", self.mirror_base(), PYTHON_RELEASE_PATH)
}
/// uv 下载 Python 的目录(解析后的绝对路径)。
pub fn python_install_dir(&self) -> PathBuf {
let value = self
.extra_env
.get(PYTHON_INSTALL_DIR_ENV)
.map(String::as_str)
.unwrap_or(PYTHON_DIR_DEFAULT);
resolve_platform_path(value)
}
/// uv 的缓存目录(解析后的绝对路径)。
pub fn cache_dir(&self) -> PathBuf {
let value = self
.extra_env
.get(CACHE_DIR_ENV)
.map(String::as_str)
.unwrap_or(CACHE_DIR_DEFAULT);
resolve_platform_path(value)
}
}
/// 两个路径型 UV 变量的默认值:相对程序目录,随包移动;也可以改成绝对路径。
const UV_PATH_DEFAULTS: [(&str, &str); 2] = [
(PYTHON_INSTALL_DIR_ENV, PYTHON_DIR_DEFAULT),
(CACHE_DIR_ENV, CACHE_DIR_DEFAULT),
];
/// 相对路径按程序目录解析,绝对路径原样返回。
fn resolve_platform_path(value: &str) -> PathBuf {
let path = Path::new(value.trim());
if path.is_absolute() {
path.to_path_buf()
} else {
executable_directory().join(path)
}
}
/// 程序目录下的 `uv` 目录(uv 下载后放这里)。
pub fn managed_directory() -> PathBuf {
executable_directory().join(UV_DIRECTORY)
}
fn executable_directory() -> PathBuf {
env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
/// 运行 `<program> --version`;不是可用的 uv 时返回 `None`。
pub fn probe_version(program: &Path) -> Option<String> {
let mut command = std::process::Command::new(program);
command
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
version
.to_ascii_lowercase()
.starts_with("uv ")
.then_some(version)
}
/// 当前架构对应的 Windows 压缩包名。
const fn archive_name() -> &'static str {
if cfg!(target_arch = "aarch64") {
"uv-aarch64-pc-windows-msvc.zip"
} else {
"uv-x86_64-pc-windows-msvc.zip"
}
}
/// 从 `<zip>.sha256` 里取第一段十六进制摘要。
fn parse_sha256(text: &str) -> Option<String> {
let token = text.split_whitespace().next()?;
(token.len() == 64 && token.chars().all(|character| character.is_ascii_hexdigit()))
.then(|| token.to_ascii_lowercase())
}
fn hex_digest(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut hex = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(hex, "{byte:02x}");
}
hex
}
fn extract_uv_exe(archive: &[u8], destination: &Path) -> Result<(), String> {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive))
.map_err(|error| format!("uv 压缩包无效: {error}"))?;
let mut entry = archive
.by_name(UV_EXECUTABLE)
.map_err(|_| format!("uv 压缩包里没有 {UV_EXECUTABLE}"))?;
let mut file =
std::fs::File::create(destination).map_err(|error| format!("无法写入 uv: {error}"))?;
std::io::copy(&mut entry, &mut file).map_err(|error| format!("解压 uv 失败: {error}"))?;
Ok(())
}
/// 解压到临时文件、验证可运行、再替换掉旧的 uv。
fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
std::fs::create_dir_all(managed).map_err(|error| format!("无法创建 uv 目录: {error}"))?;
let target = managed.join(UV_EXECUTABLE);
let temporary = managed.join(format!("uv-download-{}.tmp", Uuid::new_v4().simple()));
extract_uv_exe(archive, &temporary)?;
if probe_version(&temporary).is_none() {
let _ = std::fs::remove_file(&temporary);
return Err("下载的 uv 无法运行,已丢弃".to_owned());
}
if target.exists() {
std::fs::remove_file(&target).map_err(|error| format!("无法替换旧的 uv: {error}"))?;
}
std::fs::rename(&temporary, &target).map_err(|error| format!("无法安装 uv: {error}"))?;
probe_version(&target).ok_or_else(|| "安装后的 uv 无法运行".to_owned())
}
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
async fn download_and_install(base: &str) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.user_agent("TaskPilot/0.1")
.build()
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
let url = format!("{}/{}", base.trim_end_matches('/'), archive_name());
let checksum = client
.get(format!("{url}.sha256"))
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| format!("下载 uv 校验文件失败: {error}"))?
.text()
.await
.map_err(|error| format!("读取 uv 校验文件失败: {error}"))?;
let expected = parse_sha256(&checksum).ok_or_else(|| "uv 校验文件格式不正确".to_owned())?;
let response = client
.get(&url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| format!("下载 uv 失败: {error}"))?;
if response
.content_length()
.is_some_and(|length| length > MAX_UV_ARCHIVE_BYTES)
{
return Err("uv 压缩包超出大小上限".to_owned());
}
let archive = response
.bytes()
.await
.map_err(|error| format!("读取 uv 压缩包失败: {error}"))?;
if archive.len() as u64 > MAX_UV_ARCHIVE_BYTES {
return Err("uv 压缩包超出大小上限".to_owned());
}
if hex_digest(&Sha256::digest(&archive)) != expected {
return Err("uv 压缩包 sha256 校验失败".to_owned());
}
let managed = managed_directory();
tokio::task::spawn_blocking(move || install_archive(&archive, &managed))
.await
.map_err(|error| format!("安装 uv 失败: {error}"))?
}
#[derive(Debug, Serialize, ToSchema)]
pub struct UvCandidate {
pub source: UvSource,
pub path: String,
pub available: bool,
pub version: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct UvSettingsResponse {
pub source: UvSource,
pub custom_path: Option<String>,
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
pub extra_env: BTreeMap<String, String>,
/// 程序目录下的 uv 目录
pub managed_dir: String,
/// 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)
pub python_dir: String,
/// 实际生效的 uv 缓存目录(绝对路径)
pub cache_dir: String,
/// 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认
pub mirror_url: String,
/// uv 自身压缩包的下载地址前缀
pub uv_download_url: String,
/// Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)
pub python_mirror_url: String,
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
pub env_defaults: BTreeMap<String, String>,
/// 当前设置指向的 uv;不可用时 `version` 为空
pub executable: Option<String>,
pub version: Option<String>,
/// 各来源的探测结果
pub candidates: Vec<UvCandidate>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateUvSettingsRequest {
pub source: UvSource,
#[serde(default)]
pub custom_path: Option<String>,
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
#[serde(default)]
pub extra_env: BTreeMap<String, String>,
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
#[serde(default)]
pub mirror_url: Option<String>,
}
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
pub async fn current(pool: &SqlitePool) -> UvSettings {
match load(pool).await {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(%error, "读取 uv 设置失败,回退到程序目录 uv");
UvSettings::default()
}
}
}
fn candidate(source: UvSource, program: &Path) -> UvCandidate {
let version = probe_version(program);
UvCandidate {
source,
path: program.to_string_lossy().into_owned(),
available: version.is_some(),
version,
}
}
fn describe(settings: &UvSettings) -> UvSettingsResponse {
let managed_directory = managed_directory();
let mut candidates = vec![candidate(
UvSource::Managed,
&managed_directory.join(UV_EXECUTABLE),
)];
if let Some(path) = settings
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
candidates.push(candidate(UvSource::Custom, Path::new(path)));
}
let current = candidates
.iter()
.find(|candidate| candidate.source == settings.source);
UvSettingsResponse {
source: settings.source,
custom_path: settings.custom_path.clone(),
extra_env: settings.extra_env.clone(),
managed_dir: managed_directory.to_string_lossy().into_owned(),
python_dir: settings.python_install_dir().to_string_lossy().into_owned(),
cache_dir: settings.cache_dir().to_string_lossy().into_owned(),
mirror_url: settings.mirror_base(),
uv_download_url: settings.uv_download_base(),
python_mirror_url: settings
.extra_env
.get(PYTHON_MIRROR_ENV)
.cloned()
.unwrap_or_else(|| settings.python_mirror_url()),
env_defaults: {
let mut defaults: BTreeMap<String, String> = UV_PATH_DEFAULTS
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect();
defaults.insert(
PYTHON_MIRROR_ENV.to_owned(),
settings.python_mirror_url(),
);
defaults
},
executable: current.map(|candidate| candidate.path.clone()),
version: current.and_then(|candidate| candidate.version.clone()),
candidates,
}
}
/// 只允许 `UV_` 前缀:这组变量是注入给 uv 进程的,不能借它改 PATH 之类的东西。
fn validate_extra_env(extra_env: &BTreeMap<String, String>) -> ApiResult<BTreeMap<String, String>> {
if extra_env.len() > MAX_UV_ENV_ENTRIES {
return Err(ApiError::validation(
"extra_env",
format!("环境变量最多 {MAX_UV_ENV_ENTRIES} 条"),
));
}
let mut cleaned = BTreeMap::new();
for (name, value) in extra_env {
let name = name.trim();
let valid_name = name.starts_with("UV_")
&& name.len() > 3
&& name.chars().all(|character| {
character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
});
if !valid_name {
return Err(ApiError::validation(
"extra_env",
format!("环境变量名必须是大写的 UV_ 前缀: {name}"),
));
}
let value = value.trim();
if value.is_empty() {
return Err(ApiError::validation(
"extra_env",
format!("{name} 的值不能为空"),
));
}
if value.chars().count() > MAX_UV_ENV_VALUE_CHARS {
return Err(ApiError::validation(
"extra_env",
format!("{name} 的值不能超过 {MAX_UV_ENV_VALUE_CHARS} 个字符"),
));
}
cleaned.insert(name.to_owned(), value.to_owned());
}
Ok(cleaned)
}
fn validate(request: UpdateUvSettingsRequest) -> ApiResult<UvSettings> {
let custom_path = request
.custom_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
if request.source == UvSource::Custom {
let path = custom_path.as_deref().ok_or_else(|| {
ApiError::validation("custom_path", "请填写自己下载的 uv 可执行文件路径")
})?;
if !Path::new(path).is_file() {
return Err(ApiError::validation(
"custom_path",
format!("找不到 uv 可执行文件: {path}"),
));
}
if probe_version(Path::new(path)).is_none() {
return Err(ApiError::validation(
"custom_path",
"该文件无法运行 `uv --version`,它可能不是 uv",
));
}
}
Ok(UvSettings {
source: request.source,
custom_path,
extra_env: validate_extra_env(&request.extra_env)?,
mirror_url: validate_mirror_url(request.mirror_url)?,
})
}
fn validate_mirror_url(value: Option<String>) -> ApiResult<Option<String>> {
let Some(raw) = value else {
return Ok(None);
};
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > MAX_MIRROR_URL_CHARS {
return Err(ApiError::validation("url", "镜像地址过长"));
}
let parsed = Url::parse(trimmed)
.map_err(|_| ApiError::validation("url", "镜像地址不是有效的 URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(ApiError::validation("url", "镜像地址只支持 http 或 https"));
}
if parsed.host_str().is_none() {
return Err(ApiError::validation("url", "镜像地址缺少主机名"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ApiError::validation("url", "镜像地址不能包含用户名或密码"));
}
Ok(Some(trimmed.to_owned()))
}
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
let row = sqlx::query_as::<_, (String, Option<String>, String, Option<String>)>(
"SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1",
)
.fetch_optional(pool)
.await?;
let Some((source, custom_path, extra_env, mirror_url)) = row else {
return Ok(UvSettings::default());
};
Ok(UvSettings {
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
custom_path,
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
mirror_url,
})
}
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
let extra_env = serde_json::to_string(&settings.extra_env)?;
sqlx::query(
"INSERT INTO uv_settings (id, source, custom_path, extra_env, mirror_url, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
source = excluded.source,
custom_path = excluded.custom_path,
extra_env = excluded.extra_env,
mirror_url = excluded.mirror_url,
updated_at = excluded.updated_at",
)
.bind(settings.source.as_str())
.bind(settings.custom_path.as_deref())
.bind(extra_env)
.bind(settings.mirror_url.as_deref())
.bind(Utc::now())
.execute(pool)
.await?;
Ok(())
}
#[utoipa::path(
get,
path = "/api/v1/uv",
tag = "uv",
responses(
(status = 200, description = "uv settings and detected executables", body = UvSettingsResponse)
)
)]
pub async fn get_uv_settings(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
let settings = load(&state.pool).await?;
tokio::task::spawn_blocking(move || describe(&settings))
.await
.map(Json)
.map_err(ApiError::internal)
}
#[utoipa::path(
put,
path = "/api/v1/uv",
tag = "uv",
request_body = UpdateUvSettingsRequest,
responses(
(status = 200, description = "uv settings saved", body = UvSettingsResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse)
)
)]
pub async fn update_uv_settings(
State(state): State<AppState>,
ApiJson(request): ApiJson<UpdateUvSettingsRequest>,
) -> ApiResult<Json<UvSettingsResponse>> {
let settings = tokio::task::spawn_blocking(move || validate(request))
.await
.map_err(ApiError::internal)??;
save(&state.pool, &settings).await?;
tokio::task::spawn_blocking(move || describe(&settings))
.await
.map(Json)
.map_err(ApiError::internal)
}
#[utoipa::path(
post,
path = "/api/v1/uv/download",
tag = "uv",
responses(
(status = 200, description = "uv downloaded from the mirror and switched to it", body = UvSettingsResponse),
(status = 400, description = "Download, verification or install failed", body = crate::error::ErrorResponse)
)
)]
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
let mut settings = load(&state.pool).await?;
let version = download_and_install(&settings.uv_download_base())
.await
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
tracing::info!(%version, "downloaded uv into the managed directory");
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
settings.source = UvSource::Managed;
save(&state.pool, &settings).await?;
tokio::task::spawn_blocking(move || describe(&settings))
.await
.map(Json)
.map_err(ApiError::internal)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/uv", get(get_uv_settings).put(update_uv_settings))
.route("/api/v1/uv/download", post(download_uv))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn managed_directory_sits_next_to_the_binary() {
let managed = managed_directory();
assert_eq!(managed.file_name(), Some(std::ffi::OsStr::new("uv")));
assert_eq!(managed.parent(), env::current_exe().unwrap().parent());
}
fn request(source: UvSource, custom_path: Option<&str>) -> UpdateUvSettingsRequest {
UpdateUvSettingsRequest {
source,
custom_path: custom_path.map(str::to_owned),
extra_env: BTreeMap::new(),
mirror_url: None,
}
}
#[test]
fn program_follows_the_configured_source() {
let custom = UvSettings {
source: UvSource::Custom,
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
extra_env: BTreeMap::new(),
mirror_url: None,
};
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
let managed = UvSettings {
source: UvSource::Managed,
custom_path: None,
extra_env: BTreeMap::new(),
mirror_url: None,
};
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
}
#[test]
fn rejects_custom_source_without_a_runnable_uv() {
assert!(validate(request(UvSource::Custom, None)).is_err());
assert!(
validate(request(
UvSource::Custom,
Some(r"C:\definitely\not\here\uv.exe")
))
.is_err()
);
}
#[test]
fn keeps_the_custom_path_when_switching_away_from_custom() {
let settings = validate(request(UvSource::Managed, Some(" C:\\tools\\uv.exe "))).unwrap();
assert_eq!(settings.source, UvSource::Managed);
assert_eq!(settings.custom_path.as_deref(), Some(r"C:\tools\uv.exe"));
}
#[test]
fn accepts_uv_prefixed_environment_variables() {
let mut extra_env = BTreeMap::new();
extra_env.insert("UV_CACHE_DIR".to_owned(), " D:\\uv-cache ".to_owned());
extra_env.insert("UV_HTTP_TIMEOUT".to_owned(), "120".to_owned());
let settings = validate(UpdateUvSettingsRequest {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
})
.unwrap();
assert_eq!(
settings.extra_env.get("UV_CACHE_DIR").map(String::as_str),
Some("D:\\uv-cache")
);
}
#[test]
fn rejects_non_uv_environment_variables() {
for name in ["PATH", "uv_cache_dir", "UV_", "UV-CACHE"] {
let mut extra_env = BTreeMap::new();
extra_env.insert(name.to_owned(), "x".to_owned());
let result = validate(UpdateUvSettingsRequest {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
});
assert!(result.is_err(), "{name} should be rejected");
}
let mut extra_env = BTreeMap::new();
extra_env.insert("UV_CACHE_DIR".to_owned(), " ".to_owned());
assert!(
validate(UpdateUvSettingsRequest {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
})
.is_err()
);
}
#[test]
fn picks_the_windows_archive_for_this_architecture() {
let name = archive_name();
assert!(name.starts_with("uv-"));
assert!(name.ends_with("-pc-windows-msvc.zip"));
}
#[test]
fn parses_the_sha256_sidecar() {
let digest = "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef";
let text = format!("{digest} uv-x86_64-pc-windows-msvc.zip\n");
assert_eq!(
parse_sha256(&text).as_deref(),
Some(digest.to_ascii_lowercase().as_str())
);
assert!(parse_sha256("not a checksum").is_none());
assert!(parse_sha256("").is_none());
}
#[test]
fn derives_uv_and_python_urls_from_the_mirror_base() {
let settings = UvSettings {
mirror_url: Some("https://mirror.example/github-release/".to_owned()),
..UvSettings::default()
};
assert_eq!(settings.mirror_base(), "https://mirror.example/github-release");
assert_eq!(
settings.uv_download_base(),
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
settings.python_mirror_url(),
"https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download"
);
}
#[test]
fn falls_back_to_the_builtin_mirror() {
let settings = UvSettings::default();
assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE);
assert_eq!(
settings.uv_download_base(),
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
settings.python_mirror_url(),
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
}
#[test]
fn validates_mirror_urls() {
assert_eq!(validate_mirror_url(None).unwrap(), None);
assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None);
assert_eq!(
validate_mirror_url(Some(
"https://mirrors.ustc.edu.cn/github-release/".to_owned()
))
.unwrap()
.as_deref(),
Some("https://mirrors.ustc.edu.cn/github-release")
);
assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err());
assert!(validate_mirror_url(Some("not a url".to_owned())).is_err());
assert!(validate_mirror_url(Some("https://user:[email protected]".to_owned())).is_err());
}
#[test]
fn injects_the_python_mirror_and_keeps_user_overrides() {
let settings = UvSettings::default();
assert_eq!(
settings
.env()
.get("UV_PYTHON_INSTALL_MIRROR")
.map(String::as_str),
Some(
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
)
);
let mut extra_env = BTreeMap::new();
extra_env.insert(
"UV_PYTHON_INSTALL_MIRROR".to_owned(),
"https://my.mirror/python".to_owned(),
);
let custom = UvSettings {
extra_env,
..UvSettings::default()
};
assert_eq!(
custom
.env()
.get("UV_PYTHON_INSTALL_MIRROR")
.map(String::as_str),
Some("https://my.mirror/python")
);
}
#[test]
fn extracts_uv_from_the_archive() {
use std::io::Write;
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut buffer);
writer
.start_file(UV_EXECUTABLE, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"fake uv").unwrap();
writer.finish().unwrap();
}
let archive = buffer.into_inner();
let directory = tempfile::TempDir::new().unwrap();
let target = directory.path().join(UV_EXECUTABLE);
extract_uv_exe(&archive, &target).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"fake uv");
}
}
+24
View File
@@ -131,6 +131,30 @@ async fn directory_listing_endpoint_returns_subdirectories() {
);
}
#[tokio::test]
async fn directory_listing_endpoint_includes_files_when_requested() {
let (app, temp_dir) = test_app().await;
let tools = temp_dir.path().join("tools");
fs::create_dir_all(tools.join("bin")).expect("bin directory should be created");
fs::write(tools.join("uv.exe"), "x").expect("file should be written");
let uri = format!(
"/api/v1/system/directories?path={}&include_files=true",
encode_query_path(&tools)
);
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
let entries = body["entries"]
.as_array()
.expect("entries should be an array");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["name"], "bin");
assert_eq!(entries[0]["is_dir"], true);
assert_eq!(entries[1]["name"], "uv.exe");
assert_eq!(entries[1]["is_dir"], false);
}
#[tokio::test]
async fn directory_listing_endpoint_rejects_relative_paths() {
let (app, _temp_dir) = test_app().await;
+4 -5
View File
@@ -225,17 +225,16 @@ async fn venv_probe_reports_project_dependency_files() {
}
#[tokio::test]
async fn venv_create_rejects_project_without_dependency_files() {
async fn venv_create_rejects_missing_working_directory() {
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 absent = temp_dir.path().join("absent-project");
let (status, body) = post_create(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8")
"working_dir": absent.to_str().expect("absent path should be UTF-8")
}),
)
.await;
@@ -246,7 +245,7 @@ async fn venv_create_rejects_project_without_dependency_files() {
body["message"]
.as_str()
.expect("message should be a string")
.contains("pyproject.toml")
.contains("工作目录")
);
}
+174
View File
@@ -0,0 +1,174 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn auth_token(app: &axum::Router) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/session")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("token should be a string")
.to_owned()
}
async fn send(
app: &axum::Router,
method: Method,
uri: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&body).expect("body should serialize"),
))
.expect("request should build")
} else {
builder.body(Body::empty()).expect("request should build")
};
let response = app
.clone()
.oneshot(request)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn managed_env_list_returns_an_array() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send(&app, Method::GET, "/api/v1/python/envs", None, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_array(), "envs should be an array: {body}");
}
#[tokio::test]
async fn managed_env_create_validates_input() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "../escape" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "name");
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "data-science", "python_version": "latest" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "python_version");
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "data-science", "packages": ["--index-url=http://x"] })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "packages");
}
#[tokio::test]
async fn missing_managed_env_returns_not_found() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send(
&app,
Method::GET,
"/api/v1/python/envs/absent/packages",
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "ENV_NOT_FOUND");
let (status, body) = send(
&app,
Method::DELETE,
"/api/v1/python/envs/absent",
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "ENV_NOT_FOUND");
}
#[tokio::test]
async fn managed_env_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
None,
Some(json!({ "name": "no-token" })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+46 -5
View File
@@ -201,6 +201,46 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
panic!("run {run_id} did not reach a terminal state");
}
#[tokio::test]
async fn python_task_without_venv_fails_with_editor_hint() {
let app = test_app().await;
let working_dir = app._temp_dir.path().join("bare-python");
fs::create_dir_all(&working_dir).expect("working directory should be created");
fs::write(working_dir.join("main.py"), "print('hi')\n").expect("script should be written");
let mut task = script_task(
"bare_python",
&working_dir.to_string_lossy(),
"python.exe",
"main.py",
vec![],
"0 0 2 * * *",
10,
);
task["action"]["language"] = json!("python");
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
assert_eq!(create_response.status(), StatusCode::CREATED);
let run_response = send_json(
&app.router,
Method::POST,
"/api/v1/tasks/bare_python/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let completed = wait_for_run(&app.router, run["id"].as_i64().unwrap()).await;
assert_eq!(completed["status"], "failed");
let message = completed["error_message"]
.as_str()
.expect("failed run should carry an error message");
// 运行期不再自动装依赖,只提示去编辑器里创建。
assert!(message.contains("编辑器里用 uv 创建"), "{message}");
app.state.shutdown();
}
#[tokio::test]
async fn shell_language_runs_batch_file_with_cmd_c() {
let app = test_app().await;
@@ -706,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() {
)
.await;
let runs = response_json(response).await;
if let Some(item) = runs["items"]
.as_array()
.and_then(|items| items.first())
.filter(|item| item["trigger"] == "schedule")
{
// 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。
if let Some(item) = runs["items"].as_array().and_then(|items| {
items
.iter()
.find(|item| item["trigger"] == "schedule" && item["status"] != "skipped")
}) {
scheduled_run = Some(item.clone());
break;
}
+283
View File
@@ -0,0 +1,283 @@
use std::{fs, path::Path};
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn auth_token(app: &axum::Router) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/session")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("token should be a string")
.to_owned()
}
async fn send_json(
app: &axum::Router,
method: Method,
uri: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&body).expect("body should serialize"),
))
.expect("request should build")
} else {
builder.body(Body::empty()).expect("request should build")
};
let response = app
.clone()
.oneshot(request)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn uv_settings_default_to_the_managed_uv() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["source"], "managed");
assert_eq!(body["custom_path"], Value::Null);
let managed_dir = body["managed_dir"]
.as_str()
.expect("managed_dir should be a string");
assert_eq!(
Path::new(managed_dir).file_name().unwrap(),
std::ffi::OsStr::new("uv")
);
// 不再读系统 PATH,候选里只有程序目录 uv(自定义路径未设置时也不出现)。
let sources = body["candidates"]
.as_array()
.expect("candidates should be an array")
.iter()
.map(|candidate| candidate["source"].as_str().unwrap().to_owned())
.collect::<Vec<_>>();
assert_eq!(sources, vec!["managed"]);
}
#[tokio::test]
async fn uv_settings_reject_unusable_custom_path_and_persist_valid_ones() {
let (app, temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "custom", "custom_path": null })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "custom", "custom_path": "C:\\missing\\uv.exe" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
// 文件存在但不是可运行的 uv,也要拒绝保存。
let fake = temp_dir.path().join("fake.exe");
fs::write(&fake, b"not a program").expect("fake executable should be written");
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "custom",
"custom_path": fake.to_string_lossy()
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
// 切回程序目录 uv 时保留已填写的自定义路径,方便再次切换。
let (status, saved) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"custom_path": " C:\\tools\\uv.exe "
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved["source"], "managed");
assert_eq!(saved["custom_path"], "C:\\tools\\uv.exe");
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["source"], "managed");
assert_eq!(body["custom_path"], "C:\\tools\\uv.exe");
assert!(
body["candidates"]
.as_array()
.expect("candidates should be an array")
.iter()
.any(|candidate| candidate["source"] == "custom")
);
}
#[tokio::test]
async fn uv_settings_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
None,
Some(json!({ "source": "managed", "custom_path": null })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
#[tokio::test]
async fn uv_mirror_is_saved_with_the_settings_and_drives_both_urls() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["mirror_url"],
"https://mirrors.ustc.edu.cn/github-release"
);
assert_eq!(
body["uv_download_url"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
);
assert_eq!(
body["python_mirror_url"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
// 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖
assert_eq!(
body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"],
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
);
// 非法镜像地址会被拒绝
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"mirror_url": "ftp://mirror.example"
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "url");
// 保存自定义镜像:两个下载地址都由它派生
let (status, saved) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"mirror_url": "https://mirror.example/github-release/"
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved["mirror_url"], "https://mirror.example/github-release");
assert_eq!(
saved["uv_download_url"],
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
);
// 传 null 恢复内置默认
let (status, restored) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "managed", "mirror_url": null })),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
restored["mirror_url"],
"https://mirrors.ustc.edu.cn/github-release"
);
}
#[tokio::test]
async fn uv_download_requires_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::POST, "/api/v1/uv/download", None, None).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+583 -46
View File
@@ -89,6 +89,145 @@
}
}
},
"/api/v1/python/envs": {
"get": {
"tags": [
"python-envs"
],
"operationId": "list_envs",
"responses": {
"200": {
"description": "Managed virtual environments",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ManagedEnvSummary"
}
}
}
}
}
}
},
"post": {
"tags": [
"python-envs"
],
"operationId": "create_env",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateManagedEnvRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Virtual environment created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ManagedEnvSummary"
}
}
}
},
"400": {
"description": "Validation or uv failure",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/python/envs/{name}": {
"delete": {
"tags": [
"python-envs"
],
"operationId": "delete_env",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Environment name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Environment deleted"
},
"404": {
"description": "Environment not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/python/envs/{name}/packages": {
"get": {
"tags": [
"python-envs"
],
"operationId": "list_env_packages",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Environment name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Installed packages",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ManagedEnvPackage"
}
}
}
}
},
"404": {
"description": "Environment not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/python/mirrors": {
"get": {
"tags": [
@@ -343,6 +482,80 @@
}
}
},
"/api/v1/python/pythons": {
"get": {
"tags": [
"python-envs"
],
"operationId": "list_pythons",
"responses": {
"200": {
"description": "Pythons downloaded by uv into the platform directory",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ManagedPython"
}
}
}
}
},
"400": {
"description": "uv unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"post": {
"tags": [
"python-envs"
],
"operationId": "install_python",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InstallPythonRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Python downloaded; returns the installed list",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ManagedPython"
}
}
}
}
},
"400": {
"description": "Validation or uv failure",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/python/venv": {
"post": {
"tags": [
@@ -689,11 +902,20 @@
"schema": {
"type": "string"
}
},
{
"name": "include_files",
"in": "query",
"description": "是否一并返回文件(挑选可执行文件时用);默认只返回目录",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Subdirectories of the requested path, or the drive list",
"description": "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list",
"content": {
"application/json": {
"schema": {
@@ -1160,15 +1382,124 @@
}
}
}
},
"/api/v1/uv": {
"get": {
"tags": [
"uv"
],
"operationId": "get_uv_settings",
"responses": {
"200": {
"description": "uv settings and detected executables",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UvSettingsResponse"
}
}
}
}
}
},
"put": {
"tags": [
"uv"
],
"operationId": "update_uv_settings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUvSettingsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "uv settings saved",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UvSettingsResponse"
}
}
}
},
"400": {
"description": "Validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/uv/download": {
"post": {
"tags": [
"uv"
],
"operationId": "download_uv",
"responses": {
"200": {
"description": "uv downloaded from the mirror and switched to it",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UvSettingsResponse"
}
}
}
},
"400": {
"description": "Download, verification or install failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ConcurrencyPolicy": {
"type": "string",
"enum": [
"skip"
]
"CreateManagedEnvRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识"
},
"packages": {
"type": "array",
"items": {
"type": "string"
},
"description": "预装包,每行一个,例如 `numpy==2.1.0`"
},
"python_version": {
"type": [
"string",
"null"
],
"description": "创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定"
}
},
"additionalProperties": false
},
"CreatePythonMirrorRequest": {
"type": "object",
@@ -1220,12 +1551,13 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明,展示在任务卡片和详情里"
},
"enabled": {
"type": "boolean"
},
@@ -1235,9 +1567,6 @@
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -1292,9 +1621,14 @@
"type": "object",
"required": [
"name",
"path"
"path",
"is_dir"
],
"properties": {
"is_dir": {
"type": "boolean",
"description": "true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)"
},
"name": {
"type": "string",
"description": "用于展示的名称"
@@ -1398,6 +1732,19 @@
"POST"
]
},
"InstallPythonRequest": {
"type": "object",
"required": [
"version"
],
"properties": {
"version": {
"type": "string",
"description": "要下载的版本,例如 `3.12` 或 `3.12.4`"
}
},
"additionalProperties": false
},
"InterpreterKind": {
"type": "string",
"enum": [
@@ -1427,6 +1774,60 @@
}
}
},
"ManagedEnvPackage": {
"type": "object",
"required": [
"name",
"version"
],
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"ManagedEnvSummary": {
"type": "object",
"required": [
"name",
"path"
],
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string",
"description": "虚拟环境根目录的绝对路径"
},
"python_version": {
"type": [
"string",
"null"
],
"description": "取自 `pyvenv.cfg` 的 `version_info`"
}
}
},
"ManagedPython": {
"type": "object",
"required": [
"version",
"path"
],
"properties": {
"path": {
"type": "string",
"description": "uv 下载到平台目录下的解释器路径"
},
"version": {
"type": "string"
}
}
},
"PythonMirrorResponse": {
"type": "object",
"description": "一个 Python 包索引镜像(pip / uv)。",
@@ -1474,22 +1875,6 @@
}
}
},
"RetryPolicy": {
"type": "object",
"properties": {
"delay_secs": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"max_attempts": {
"type": "integer",
"format": "int32",
"minimum": 0
}
},
"additionalProperties": false
},
"RunStatus": {
"type": "string",
"enum": [
@@ -1506,8 +1891,7 @@
"type": "string",
"enum": [
"schedule",
"manual",
"retry"
"manual"
]
},
"ScheduledTaskResponse": {
@@ -1741,12 +2125,11 @@
"required": [
"id",
"name",
"description",
"cron",
"timezone",
"enabled",
"timeout_secs",
"concurrency_policy",
"retry_policy",
"action",
"created_at",
"updated_at"
@@ -1755,9 +2138,6 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -1765,6 +2145,10 @@
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明"
},
"enabled": {
"type": "boolean"
},
@@ -1774,9 +2158,6 @@
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -1974,21 +2355,19 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明,展示在任务卡片和详情里"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -2000,6 +2379,164 @@
},
"additionalProperties": false
},
"UpdateUvSettingsRequest": {
"type": "object",
"required": [
"source"
],
"properties": {
"custom_path": {
"type": [
"string",
"null"
]
},
"extra_env": {
"type": "object",
"description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"mirror_url": {
"type": [
"string",
"null"
],
"description": "uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认"
},
"source": {
"$ref": "#/components/schemas/UvSource"
}
},
"additionalProperties": false
},
"UvCandidate": {
"type": "object",
"required": [
"source",
"path",
"available"
],
"properties": {
"available": {
"type": "boolean"
},
"path": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/UvSource"
},
"version": {
"type": [
"string",
"null"
]
}
}
},
"UvSettingsResponse": {
"type": "object",
"required": [
"source",
"extra_env",
"managed_dir",
"python_dir",
"cache_dir",
"mirror_url",
"uv_download_url",
"python_mirror_url",
"env_defaults",
"candidates"
],
"properties": {
"cache_dir": {
"type": "string",
"description": "实际生效的 uv 缓存目录(绝对路径)"
},
"candidates": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UvCandidate"
},
"description": "各来源的探测结果"
},
"custom_path": {
"type": [
"string",
"null"
]
},
"env_defaults": {
"type": "object",
"description": "两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"executable": {
"type": [
"string",
"null"
],
"description": "当前设置指向的 uv;不可用时 `version` 为空"
},
"extra_env": {
"type": "object",
"description": "额外注入给 uv 的环境变量;只允许 `UV_` 前缀",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"managed_dir": {
"type": "string",
"description": "程序目录下的 uv 目录"
},
"mirror_url": {
"type": "string",
"description": "当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认"
},
"python_dir": {
"type": "string",
"description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)"
},
"python_mirror_url": {
"type": "string",
"description": "Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)"
},
"source": {
"$ref": "#/components/schemas/UvSource"
},
"uv_download_url": {
"type": "string",
"description": "uv 自身压缩包的下载地址前缀"
},
"version": {
"type": [
"string",
"null"
]
}
}
},
"UvSource": {
"type": "string",
"description": "uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性\n会随启动方式变化,不适合当调度器的隐式依赖。",
"enum": [
"managed",
"custom"
]
},
"VenvProbeRequest": {
"type": "object",
"description": "编辑器在用户填写工作目录时发起的探测请求。",
@@ -2041,7 +2578,7 @@
"properties": {
"can_create": {
"type": "boolean",
"description": "没有虚拟环境时,是否具备用 uv 创建的条件"
"description": "没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)"
},
"display_path": {
"type": [
+19
View File
@@ -18,6 +18,25 @@
- SQLite 数据库:`data/task_pilot.db`(WAL 模式,运行中会额外出现 `-wal`/`-shm` 文件)
- 运行日志:`logs/{task_id}/{run_id}.log`
- 托管虚拟环境:`data/envs/<名称>/`(「虚拟环境」弹窗创建,删除环境就是删掉整个目录)
- uv 下载的 Python 解释器:`python/<版本>/`
- uv 缓存:`uv-cache/`(首次装包/下载 Python 后才会出现)
- 程序下载的 uv:`uv/uv.exe`
`UV_PYTHON_INSTALL_DIR` 与 `UV_CACHE_DIR` 默认是**相对本目录**的 `python` / `uv-cache`,
整个发布目录可以整体搬走;想复用机器上的全局缓存,在 uv 配置弹窗里把这两个变量改成绝对路径。
## Python 与 uv
用 uv 创建虚拟环境时,uv 的来源在 uv 配置弹窗(任务调度页右栏「uv → 配置」)切换(不读系统 PATH):
- 本目录下的 `uv/uv.exe`(默认):没有 uv 时会出现「下载 uv」,会从科大镜像下载并校验 sha256 后放进来;也可以自己下载后放到这里
- 任意位置的 `uv.exe`:选「指定路径」,保存时会先校验能运行;本机已装 uv 就把它的路径填进来
同一弹窗还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`),会注入到每一次 uv 调用;
任务运行时会把这些变量和默认镜像 `UV_DEFAULT_INDEX` 一并注入任务进程(任务自己的环境变量优先)。
「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;离线环境请自己准备 uv 并指定路径。
## 配置
@@ -0,0 +1,32 @@
# ADR-0009:uv 与托管 Python 环境
- 状态:已接受
- 日期:2026-11-07
## 背景
Python 任务需要一个可复现的运行环境,但宿主机上的工具链状态不可控:PATH 上的 uv 版本随启动方式变化(终端 / 快捷方式 / 计划任务各不相同),也可能根本没装;pip、conda 与项目 `.venv` 的组合更是无法预设。之前的实现直接依赖 PATH 里的 uv,并要求用户先手动准备好虚拟环境,出了问题要到第一次运行时才暴露。
## 决策
1. **uv 是唯一的环境工具**:建环境、装依赖、跑预装都走 uv,不依赖 `pip.exe`。
2. **uv 来源只认平台副本或指定路径**:默认 `程序目录\uv\uv.exe`,或用户指定的 `uv.exe`;**不读系统 PATH**。缺失时从科大镜像 `mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构压缩包,校验 sha256 后安装到程序目录。
3. **镜像只在装包时生效**:建环境 / 预装依赖时注入 `UV_DEFAULT_INDEX`(以及兼容的 `UV_INDEX_URL`);任务运行时不再注入 `PIP_*` 或镜像变量。
4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。
5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。
6. **托管环境**:`data/envs/<名称>` 预创建常用环境,Python 版本来自 `uv python install` 下载到平台的列表,可预装包、查看已装包。
7. **解释器下载归 uv 配置**:`uv python install` / `uv python list` 的入口放在「uv 配置」弹窗(解释器由 uv 管理、落在 `UV_PYTHON_INSTALL_DIR`);「虚拟环境」弹窗只负责从已下载列表里选版本创建环境。
## 原因
- PATH 上的 uv 版本与可用性不可控;平台副本随包分发,行为可预期。
- 相对默认值让整个发布目录可以整体搬迁;注入前解析成绝对路径,是因为 uv 的 cwd 在调用间会变化(项目目录 / 环境目录),相对路径会漂。
- 把装依赖限制在保存任务阶段,问题能当场暴露;运行期保持"只运行、不改环境"。
- 单机场景下,用户自己调试好的虚拟环境就是最可信的运行环境,应当优先复用而不是覆盖。
## 边界
- 托管环境不会自动升级依赖,也不跟随 `pyproject.toml` 变更重建。
- `uv python install` 走 uv 自己的 Python 发行源(python-build-standalone),和 PyPI 镜像不是一回事;uv 配置弹窗里配「下载镜像」统一给 uv 本体与解释器用,解释器还可以用 `UV_PYTHON_INSTALL_MIRROR` 单独覆盖。
- 删除托管环境是直接删除目录,不可恢复。
- 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。
+584 -39
View File
@@ -48,10 +48,9 @@
.page-container {
width: 100%;
max-width: 1200px;
max-width: 1320px;
min-width: 0;
margin: 0 auto;
padding: 32px 32px 48px;
}
.page-stack {
@@ -59,6 +58,7 @@
min-width: 0;
flex-direction: column;
gap: 24px;
padding: 32px 32px 48px;
}
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
@@ -139,7 +139,8 @@
}
.toolbar-search {
flex: 1 1 260px;
flex: 1 1 200px;
min-width: 180px;
max-width: 420px;
}
@@ -151,7 +152,7 @@
}
.filter-select {
min-width: 140px;
min-width: 110px;
}
.task-name-cell {
@@ -227,16 +228,6 @@
gap: 4px;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
border-top: 1px solid var(--su-border);
}
/* ---------- 任务编辑器 ---------- */
.task-editor-modal {
@@ -651,6 +642,19 @@
background-color: var(--su-bg-subtle);
}
/* 文件模式下选中的文件 */
.directory-row-selected,
.directory-row-selected:hover {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.directory-check {
margin-left: auto;
flex-shrink: 0;
color: var(--su-color-primary);
}
.directory-icon {
flex-shrink: 0;
color: var(--su-text-muted);
@@ -956,28 +960,6 @@
/* ---------- Python 镜像 ---------- */
.mirror-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.mirror-summary-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.mirror-summary-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.mirror-card {
overflow: hidden;
}
@@ -1197,9 +1179,572 @@
height: 50vh;
flex: none;
}
}
.pagination-bar {
align-items: flex-start;
flex-direction: column;
/* ---------- uv 配置弹窗 ---------- */
.uv-settings-modal {
width: min(720px, calc(100vw - 32px));
max-width: 720px;
}
.uv-settings {
display: flex;
flex-direction: column;
}
/* 标签页:三段配置共用一块面板,内容自己撑高度 */
.uv-tabs {
margin-bottom: 4px;
}
.uv-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.uv-section {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 来源那一行:路径输入框 + 右侧动作(下载 / 浏览) */
.uv-path-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
/* 程序目录的 uv:固定位置,只读展示,不做成输入框 */
.uv-path-static {
display: flex;
height: var(--su-control-height-md);
align-items: center;
gap: 8px;
overflow: hidden;
border: 1px dashed var(--su-border-strong);
border-radius: var(--su-radius-sm);
padding: 0 10px;
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
}
.uv-path-static-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uv-mirror-hint code {
font-family: var(--su-font-mono);
color: var(--su-text-sub);
}
/* 镜像源弹窗:PyPI 表格列多,比默认 lg 宽一点 */
.mirror-settings-modal {
width: min(760px, calc(100vw - 32px));
max-width: 760px;
}
/* 固定的路径变量:不可编辑、不可删除,样式上做出区别 */
.uv-env-locked {
display: flex;
height: var(--su-control-height-md);
align-items: center;
gap: 8px;
overflow: hidden;
border: 1px dashed var(--su-border-strong);
border-radius: var(--su-radius-sm);
padding: 0 10px;
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
}
.uv-env-locked-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uv-version-input {
width: 160px;
}
.uv-inline-state {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.uv-empty {
min-height: 0;
padding: 8px 0;
}
.uv-python-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.uv-python-item {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.uv-python-version {
font-variant-numeric: tabular-nums;
}
.uv-python-path {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.uv-python-item {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
}
.uv-env-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.uv-env-row {
display: grid;
grid-template-columns: minmax(200px, 260px) minmax(240px, 1fr) auto;
gap: 8px;
align-items: center;
}
@media (max-width: 720px) {
.uv-env-row {
grid-template-columns: 1fr;
}
}
.env-packages-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
}
/* 虚拟环境管理弹窗:标题 + 说明在左,操作在右 */
.env-manager-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.env-manager-head > :first-child {
min-width: 0;
flex: 1;
}
.env-manager-empty {
min-height: 0;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
}
/* ---------- 顶部导航 ---------- */
/* 整条由 Layout 的 header 承担(实底 + 下边线),这里只管内部:左品牌、右图标 */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 1320px;
height: 100%;
margin: 0 auto;
padding: 0 16px;
}
.app-header-actions {
display: flex;
flex: none;
align-items: center;
gap: 4px;
}
/* ---------- 三栏工作台:左右各 1 份、中间 2 份(参考站 3/6/3) ---------- */
.dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
padding: 16px 16px 40px;
}
.dashboard-head {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 8px 4px 0;
}
.dashboard-column {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.dash-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin-top: 12px;
}
/* 概览指标:标签与数字在各自格子里居中 */
.dash-metric {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
}
.dash-metric-value {
font-family: var(--font-display);
font-size: 22px;
font-weight: var(--su-font-weight-bold);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.dash-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.dash-list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--su-border);
}
.dash-list-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.dash-kv {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 10px;
min-width: 0;
}
.dash-kv > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 首页「镜像源」卡片:默认镜像 + 运行时注入的环境变量 */
.mirror-card-summary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
margin-top: 10px;
}
.mirror-card-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirror-card-inject {
margin-top: 8px;
}
/* 同一行放两组键值:uv 卡片里的「版本 + Python 解释器」 */
.dash-inline-kv {
display: flex;
align-items: baseline;
gap: 16px;
margin-top: 10px;
min-width: 0;
flex-wrap: wrap;
}
.dash-inline-item {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 6px;
}
.dash-inline-item > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 最近触发:任务名在左、触发时间在右,名字过长时省略 */
.dash-inline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
min-width: 0;
}
.dash-inline-name {
min-width: 0;
}
.dash-inline-time {
flex: none;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dash-inline-empty {
display: block;
margin-top: 8px;
}
/* 每个任务一张卡片:标题行(任务名 + 类型 + 活跃状态 + 右上角详情)、内容行(说明 · Cron/下次 · 操作) */
.task-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
/* 标题只占自身文字宽度:类型与活跃中标签紧跟任务名,详情按钮始终贴右 */
.task-card-title {
flex: 0 1 auto;
min-width: 0;
}
.task-card-detail {
margin-left: auto;
}
/* 标题旁的状态标签:整体比默认标签小约三分之一(高 16、字 11、圆点 4) */
.task-card-head .task-card-status-badge {
gap: 3px;
height: 16px;
padding: 0 6px;
font-size: 11px;
line-height: 1;
}
.task-card-head .task-card-status-badge > span {
width: 4px;
height: 4px;
}
.task-card {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 16px;
}
.task-card-main {
display: flex;
flex: 1 1 160px;
min-width: 0;
flex-direction: column;
gap: 2px;
}
/* 右侧调度块:Cron 与下次运行在左,操作按钮贴右,整块从更靠左的位置起排 */
.task-card-plan {
display: flex;
flex: 0 0 auto;
min-width: 136px;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.task-card-next {
display: inline-flex;
align-items: center;
border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm);
padding: 2px 8px;
color: var(--su-text-sub);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
white-space: nowrap;
}
/* 操作按钮贴卡片内容区的底边,和「下次运行」那一行齐平 */
.task-card-side {
display: flex;
flex: none;
align-self: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.task-card-actions {
justify-content: flex-end;
}
/* 任务详情弹窗:只读的键值列表 */
.detail-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.detail-value {
min-width: 0;
color: var(--su-text-main);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
overflow-wrap: anywhere;
white-space: pre-line;
}
@media (max-width: 1200px) {
.dashboard {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
}
.dashboard-right {
grid-column: 1 / -1;
flex-direction: row;
flex-wrap: wrap;
}
.dashboard-right > * {
flex: 1 1 260px;
}
}
@media (max-width: 860px) {
.dashboard {
grid-template-columns: minmax(0, 1fr);
}
.app-header {
flex-wrap: wrap;
}
}
/* ---------- 左栏用户卡片:居中的资料卡 ---------- */
.user-profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 6px 4px 2px;
text-align: center;
}
/* Avatar 的尺寸走组件自己的局部变量(见 Avatar.module.css),这里只放大一档 */
.user-profile-avatar {
--avatar-size: 84px;
--avatar-badge: 22px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: 28px;
}
.user-profile-name-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.user-profile-status {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--su-color-success);
}
/* 参考稿是描边胶囊,Tag 默认是浅底,这里补一圈边框 */
.user-profile-role {
border: 1px solid var(--su-border-strong);
background-color: transparent;
color: var(--su-text-secondary);
}
.user-profile-bio {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--su-text-muted);
font-size: 13px;
line-height: 1.7;
}
.user-profile-meta {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.user-profile-engine-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--su-color-success);
}
.user-profile-engine-dot.is-offline {
background-color: var(--su-color-danger);
}
+478 -15
View File
@@ -9,15 +9,11 @@ import App from './App'
const task: Task = {
id: 'echo_task',
name: 'Echo task',
description: 'Echo the configured message',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 300,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: {
type: 'script',
working_dir: 'D:\\Projects\\echo',
@@ -46,9 +42,74 @@ function jsonResponse(body: unknown, status = 200): Response {
interface FetchMockOptions {
directories?: (path: string | null) => Response
envs?: Array<{
name: string
path: string
python_version: string | null
}>
envPackages?: Record<string, Array<{ name: string; version: string }>>
pythons?: Array<{ version: string; path: string }>
/** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */
uvVersion?: string
}
function uvSettingsPayload(
source: string,
customPath: string | null,
managedVersion: string | null = null,
mirrorUrl: string | null = null,
) {
const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release'
return {
source,
custom_path: customPath,
extra_env: {},
env_defaults: {
UV_CACHE_DIR: 'uv-cache',
UV_PYTHON_INSTALL_DIR: 'python',
UV_PYTHON_INSTALL_MIRROR: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
},
managed_dir: 'D:\\TaskPilot\\uv',
python_dir: 'D:\\TaskPilot\\python',
cache_dir: 'D:\\TaskPilot\\uv-cache',
mirror_url: mirrorBase,
uv_download_url: `${mirrorBase}/astral-sh/uv/LatestRelease`,
python_mirror_url: `${mirrorBase}/astral-sh/python-build-standalone/releases/download`,
executable:
source === 'managed'
? 'D:\\TaskPilot\\uv\\uv.exe'
: (customPath ?? 'C:\\tools\\uv\\uv.exe'),
version: source === 'managed' ? managedVersion : 'uv 0.9.0',
candidates: [
{
source: 'managed',
path: 'D:\\TaskPilot\\uv\\uv.exe',
available: managedVersion !== null,
version: managedVersion,
},
...(customPath
? [
{
source: 'custom',
path: customPath,
available: true,
version: 'uv 0.9.0',
},
]
: []),
],
}
}
function createFetchMock(options: FetchMockOptions = {}) {
let uvSource = 'managed'
let uvCustomPath: string | null = null
let uvManagedVersion: string | null = options.uvVersion ?? null
let uvMirror: string | null = null
let managedEnvs = options.envs ?? []
let managedPythons = options.pythons ?? []
const managedPackages = options.envPackages ?? {}
return vi.fn(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
@@ -97,6 +158,85 @@ function createFetchMock(options: FetchMockOptions = {}) {
},
])
}
if (url.endsWith('/api/v1/python/pythons') && init?.method === 'POST') {
const request = JSON.parse(String(init.body)) as { version: string }
const path = `D:\\TaskPilot\\python\\cpython-${request.version}-windows-x86_64-none\\python.exe`
if (!managedPythons.some((item) => item.version === request.version)) {
managedPythons = [
...managedPythons,
{ version: request.version, path },
]
}
return jsonResponse(managedPythons)
}
if (url.endsWith('/api/v1/python/pythons')) {
return jsonResponse(managedPythons)
}
if (url.includes('/api/v1/python/envs/') && url.endsWith('/packages')) {
const name = decodeURIComponent(
url.split('/api/v1/python/envs/')[1].replace('/packages', ''),
)
return jsonResponse(managedPackages[name] ?? [])
}
if (url.endsWith('/api/v1/python/envs') && init?.method === 'POST') {
const request = JSON.parse(String(init.body)) as {
name: string
python_version?: string | null
}
const created = {
name: request.name,
path: `D:\\TaskPilot\\data\\envs\\${request.name}`,
python_version: request.python_version ?? '3.11.11',
}
managedEnvs = [...managedEnvs, created]
return jsonResponse(created, 201)
}
if (url.includes('/api/v1/python/envs/') && init?.method === 'DELETE') {
const name = decodeURIComponent(
url.split('/api/v1/python/envs/')[1],
)
managedEnvs = managedEnvs.filter((env) => env.name !== name)
return new Response(null, { status: 204 })
}
if (url.endsWith('/api/v1/python/envs')) {
return jsonResponse(managedEnvs)
}
if (url.endsWith('/api/v1/uv/download')) {
uvSource = 'managed'
uvManagedVersion = 'uv 0.9.0'
return jsonResponse(
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/uv')) {
const request = init?.body
? (JSON.parse(String(init.body)) as {
source: string
custom_path?: string | null
mirror_url?: string | null
})
: null
if (request) {
uvSource = request.source
uvCustomPath = request.custom_path ?? null
if (request.mirror_url !== undefined) {
uvMirror = request.mirror_url
}
}
return jsonResponse(
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/cron/preview')) {
return jsonResponse({
timezone: 'Asia/Shanghai',
@@ -106,6 +246,21 @@ function createFetchMock(options: FetchMockOptions = {}) {
],
})
}
if (url.endsWith('/api/v1/python/venv/create')) {
return jsonResponse({
status: 'linked',
interpreter_kind: 'python',
venv_path: 'D:\\Projects\\uvproject\\.venv',
display_path: '.venv\\Scripts\\python.exe',
source: 'working_directory',
message: '已创建虚拟环境',
uv_available: true,
has_pyproject: true,
has_requirements: false,
requirements_files: [],
can_create: false,
})
}
if (url.endsWith('/api/v1/python/venv')) {
const probe = JSON.parse(String(init?.body)) as {
working_dir: string
@@ -122,7 +277,7 @@ function createFetchMock(options: FetchMockOptions = {}) {
? '已自动关联工作目录中的虚拟环境'
: creatable
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
: '未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv',
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
uv_available: creatable,
has_pyproject: creatable,
has_requirements: false,
@@ -183,34 +338,342 @@ describe('TaskDashboard', () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
// 任务名会同时出现在卡片和左下角「最近触发」里
expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0)
expect(
screen.getByText('Echo the configured message'),
).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
// 顶部品牌栏和用户卡片都会显示引擎状态
expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0)
})
it('navigates to python mirrors from the sidebar', async () => {
it('opens the uv settings dialog from the right column', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('button', { name: 'Python 镜像' }),
await screen.findByRole('button', { name: '配置 uv' }),
)
expect(
await screen.findByRole('heading', { name: 'Python 镜像' }),
await screen.findByRole('heading', { name: 'uv 配置' }),
).toBeInTheDocument()
// 三段配置改用标签页切换:可执行文件 / 环境变量 / Python 解释器
expect(screen.getByRole('tab', { name: 'uv 可执行文件' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /环境变量/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Python 解释器/ })).toBeInTheDocument()
// 顶部不再重复首页已有的版本 / 数量 / 镜像地址
expect(screen.queryByText(/uv 下载/)).toBeNull()
})
it('saves the uv download mirror from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
const input = await screen.findByLabelText('下载镜像')
await user.clear(input)
await user.type(input, 'https://mirror.example/github-release')
await user.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
),
).toBe(true)
})
const saved = fetchMock.mock.calls.find(
([request, init]) =>
String(request).endsWith('/api/v1/uv') && init?.method === 'PUT',
)
expect(JSON.parse(String(saved?.[1]?.body)).mirror_url).toBe(
'https://mirror.example/github-release',
)
})
it('saves the uv executable and env vars from the uv dialog', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
expect(
await screen.findByRole('row', { name: /清华 TUNA/ }),
await screen.findByRole('button', { name: '下载 uv' }),
).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.type(
screen.getByLabelText('uv 可执行文件'),
'D:\\tools\\uv.exe',
)
// 环境变量在第二个标签页里
await user.click(screen.getByRole('tab', { name: /环境变量/ }))
// 三个关键变量(两个路径 + 解释器镜像)固定在列表里,不能删除
const uvDialog = await screen.findByRole('dialog', { name: 'uv 配置' })
expect(
within(uvDialog).queryByRole('button', { name: '删除' }),
).toBeNull()
await user.click(screen.getByRole('button', { name: '+ 添加变量' }))
await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT')
await user.type(screen.getByLabelText('环境变量值 4'), '60')
await user.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
),
).toBe(true)
})
const saved = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/uv') && init?.method === 'PUT',
)
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
source: 'custom',
custom_path: 'D:\\tools\\uv.exe',
extra_env: { UV_HTTP_TIMEOUT: '60' },
mirror_url: 'https://mirrors.ustc.edu.cn/github-release',
})
})
it('picks the uv executable through the file browser', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
directories: (path) =>
jsonResponse(
path
? {
path,
parent: 'C:\\',
entries: [
{
name: 'uv.exe',
path: `${path}${path.endsWith('\\') ? '' : '\\'}uv.exe`,
is_dir: false,
},
],
truncated: false,
}
: {
path: null,
parent: null,
entries: [{ name: 'C:\\', path: 'C:\\', is_dir: true }],
truncated: false,
},
),
}),
)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('radio', { name: '指定路径' }))
await user.click(screen.getByRole('button', { name: '浏览' }))
expect(
await screen.findByRole('heading', { name: '选择 uv 可执行文件' }),
).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'C:\\' }))
await user.click(await screen.findByRole('button', { name: 'uv.exe' }))
await user.click(screen.getByRole('button', { name: '使用此文件' }))
expect(screen.getByLabelText('uv 可执行文件')).toHaveValue('C:\\uv.exe')
})
it('downloads uv from the mirror and switches to it', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(await screen.findByRole('button', { name: '下载 uv' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).endsWith('/api/v1/uv/download') &&
init?.method === 'POST',
),
).toBe(true)
})
expect((await screen.findAllByText('0.9.0')).length).toBeGreaterThan(0)
})
it('downloads a python version from the uv dialog', async () => {
const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' })
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(screen.getByRole('tab', { name: /Python 解释器/ }))
await user.type(screen.getByLabelText('要下载的 Python 版本'), '3.12')
await user.click(screen.getByRole('button', { name: '下载 Python' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).endsWith('/api/v1/python/pythons') &&
init?.method === 'POST',
),
).toBe(true)
})
const installed = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/python/pythons') &&
init?.method === 'POST',
)
expect(JSON.parse(String(installed?.[1]?.body))).toEqual({
version: '3.12',
})
})
it('creates a managed python environment with prefetched packages', async () => {
const fetchMock = createFetchMock({
pythons: [
{
version: '3.11.11',
path: 'D:\\TaskPilot\\python\\cpython-3.11.11-windows-x86_64-none\\python.exe',
},
],
})
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '管理' }))
const envDialog = await screen.findByRole('dialog', {
name: '虚拟环境管理',
})
expect(
await within(envDialog).findByText(/还没有预创建的环境/),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
await user.type(screen.getByLabelText('环境名'), 'data-science')
await user.click(screen.getByRole('button', { name: '默认(uv 自动选)' }))
await user.click(await screen.findByRole('option', { name: '3.11.11' }))
await user.type(
screen.getByLabelText(/预装包/),
'numpy==2.1.0{enter}pandas',
)
await user.click(screen.getByRole('button', { name: '创建' }))
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).endsWith('/api/v1/python/envs') &&
init?.method === 'POST',
),
).toBe(true)
})
const created = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/python/envs') &&
init?.method === 'POST',
)
expect(JSON.parse(String(created?.[1]?.body))).toEqual({
name: 'data-science',
python_version: '3.11.11',
packages: ['numpy==2.1.0', 'pandas'],
})
})
it('shows installed packages of a managed environment', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
envs: [
{
name: 'data-science',
path: 'D:\\TaskPilot\\data\\envs\\data-science',
python_version: '3.11.11',
},
],
envPackages: {
'data-science': [{ name: 'numpy', version: '2.1.0' }],
},
}),
)
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '管理' }))
await user.click(await screen.findByRole('button', { name: '查看包' }))
expect(await screen.findByText('numpy')).toBeInTheDocument()
expect(screen.getByText('2.1.0')).toBeInTheDocument()
})
it('creates the python environment before saving a new task', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.clear(screen.getByLabelText(/任务标识/))
await user.type(screen.getByLabelText(/任务标识/), 'uv_env_task')
await user.type(screen.getByLabelText('名称'), 'uv env task')
await user.type(
screen.getByLabelText(/项目工作目录/),
'D:\\Projects\\uvproject',
)
await user.click(screen.getByRole('button', { name: '保存并热刷新' }))
await waitFor(() => {
const requests = fetchMock.mock.calls.map(([input, init]) => ({
url: String(input),
method: init?.method,
}))
const venvIndex = requests.findIndex((request) =>
request.url.endsWith('/api/v1/python/venv/create'),
)
const taskIndex = requests.findIndex(
(request) =>
request.url.endsWith('/api/v1/tasks') && request.method === 'POST',
)
expect(venvIndex).toBeGreaterThanOrEqual(0)
expect(taskIndex).toBeGreaterThan(venvIndex)
})
})
it('opens the task details from the card', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '详情' }))
const dialog = await screen.findByRole('dialog', { name: '任务详情' })
expect(within(dialog).getByText('echo_task')).toBeInTheDocument()
expect(
within(dialog).getByText('Echo the configured message'),
).toBeInTheDocument()
expect(within(dialog).getByText('D:\\Projects\\echo')).toBeInTheDocument()
})
it('shows the next run time reported by the schedule endpoint', async () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText(/下次/)).toBeInTheDocument()
// 卡片里直接显示排好的触发时间(MM/DD HH:mm)
expect(
(await screen.findAllByText(/^\d{2}\/\d{2} \d{2}:\d{2}$/)).length,
).toBeGreaterThan(0)
})
it('creates a script task with the visible form values', async () => {
@@ -284,7 +747,7 @@ describe('TaskDashboard', () => {
)
expect(
await screen.findByText(/未检测到虚拟环境;本机未安装 uv/, {}, {
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
timeout: 3000,
}),
).toBeInTheDocument()
+3 -5
View File
@@ -1,7 +1,6 @@
import { Button, Layout, Result } from '@stash/ui'
import { Route, Routes, useNavigate } from 'react-router-dom'
import { AppSidebar } from './components/AppSidebar'
import { PythonMirrorsPage } from './pages/PythonMirrorsPage'
import { AppHeader } from './components/AppHeader'
import { TaskDashboard } from './pages/TaskDashboard'
import './App.css'
@@ -24,15 +23,14 @@ function App() {
return (
<Layout
className="app-root"
sider={<AppSidebar />}
siderWidth={224}
header={<AppHeader />}
headerVariant="solid"
maxWidth="full"
contentPadding="none"
>
<div className="page-container">
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="/mirrors" element={<PythonMirrorsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
+83 -1
View File
@@ -33,6 +33,17 @@ export type CreatePythonMirrorRequest =
components['schemas']['CreatePythonMirrorRequest']
export type UpdatePythonMirrorRequest =
components['schemas']['UpdatePythonMirrorRequest']
export type UvSettings = components['schemas']['UvSettingsResponse']
export type UvCandidate = components['schemas']['UvCandidate']
export type UvSource = components['schemas']['UvSource']
export type UpdateUvSettingsRequest =
components['schemas']['UpdateUvSettingsRequest']
export type ManagedEnv = components['schemas']['ManagedEnvSummary']
export type ManagedEnvPackage = components['schemas']['ManagedEnvPackage']
export type CreateManagedEnvRequest =
components['schemas']['CreateManagedEnvRequest']
export type ManagedPython = components['schemas']['ManagedPython']
export type InstallPythonRequest = components['schemas']['InstallPythonRequest']
export interface TaskFilters {
q?: string
@@ -300,13 +311,18 @@ export function createPythonVenv(
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
*/
export function listDirectories(
path?: string,
options: { includeFiles?: boolean } = {},
signal?: AbortSignal,
): Promise<DirectoryListingResponse> {
return requestJson<DirectoryListingResponse>(
withQuery('/api/v1/system/directories', { path }),
withQuery('/api/v1/system/directories', {
path,
include_files: options.includeFiles ? 'true' : undefined,
}),
{ signal },
)
}
@@ -359,3 +375,69 @@ export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
{ method: 'POST' },
)
}
/** uv 设置:只用程序目录 uv 或自己指定的可执行文件(不读系统 PATH)。 */
export function getUvSettings(signal?: AbortSignal): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv', { signal })
}
export function updateUvSettings(
request: UpdateUvSettingsRequest,
): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv', {
method: 'PUT',
body: JSON.stringify(request),
})
}
/** 本机没有 uv 时,从镜像下载到程序目录并切换过去。 */
export function downloadUv(): Promise<UvSettings> {
return requestJson<UvSettings>('/api/v1/uv/download', { method: 'POST' })
}
/** 预创建的托管虚拟环境(放在 data/envs 下)。 */
export function listManagedEnvs(
signal?: AbortSignal,
): Promise<ManagedEnv[]> {
return requestJson<ManagedEnv[]>('/api/v1/python/envs', { signal })
}
export function createManagedEnv(
request: CreateManagedEnvRequest,
): Promise<ManagedEnv> {
return requestJson<ManagedEnv>('/api/v1/python/envs', {
method: 'POST',
body: JSON.stringify(request),
})
}
export function listManagedEnvPackages(
name: string,
signal?: AbortSignal,
): Promise<ManagedEnvPackage[]> {
return requestJson<ManagedEnvPackage[]>(
`/api/v1/python/envs/${encodeURIComponent(name)}/packages`,
{ signal },
)
}
export function deleteManagedEnv(name: string): Promise<void> {
return requestJson<void>(
`/api/v1/python/envs/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
}
/** uv 下载到平台目录(`python\`)里的解释器。 */
export function listPythons(signal?: AbortSignal): Promise<ManagedPython[]> {
return requestJson<ManagedPython[]>('/api/v1/python/pythons', { signal })
}
export function installPython(
request: InstallPythonRequest,
): Promise<ManagedPython[]> {
return requestJson<ManagedPython[]>('/api/v1/python/pythons', {
method: 'POST',
body: JSON.stringify(request),
})
}
+447 -17
View File
@@ -52,6 +52,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/python/envs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_envs"];
put?: never;
post: operations["create_env"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/envs/{name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["delete_env"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/envs/{name}/packages": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_env_packages"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/mirrors": {
parameters: {
query?: never;
@@ -100,6 +148,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/python/pythons": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_pythons"];
put?: never;
post: operations["install_python"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/venv": {
parameters: {
query?: never;
@@ -356,12 +420,50 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/uv": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_uv_settings"];
put: operations["update_uv_settings"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/uv/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["download_uv"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateManagedEnvRequest: {
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
name: string;
/** @description 预装包,每行一个,例如 `numpy==2.1.0` */
packages?: string[];
/** @description 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定 */
python_version?: string | null;
};
CreatePythonMirrorRequest: {
enabled?: boolean;
index_url: string;
@@ -374,12 +476,12 @@ export interface components {
};
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
@@ -394,6 +496,8 @@ export interface components {
timezone: string;
};
DirectoryEntry: {
/** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */
is_dir: boolean;
/** @description 用于展示的名称 */
name: string;
/** @description 绝对路径,选中后回填到表单 */
@@ -426,6 +530,10 @@ export interface components {
};
/** @enum {string} */
HttpMethod: "GET" | "POST";
InstallPythonRequest: {
/** @description 要下载的版本,例如 `3.12` 或 `3.12.4` */
version: string;
};
/** @enum {string} */
InterpreterKind: "python" | "node" | "other";
LogChunkResponse: {
@@ -434,6 +542,22 @@ export interface components {
/** Format: int64 */
next_offset: number;
};
ManagedEnvPackage: {
name: string;
version: string;
};
ManagedEnvSummary: {
name: string;
/** @description 虚拟环境根目录的绝对路径 */
path: string;
/** @description 取自 `pyvenv.cfg` 的 `version_info` */
python_version?: string | null;
};
ManagedPython: {
/** @description uv 下载到平台目录下的解释器路径 */
path: string;
version: string;
};
/** @description 一个 Python 包索引镜像(pip / uv)。 */
PythonMirrorResponse: {
/** Format: date-time */
@@ -450,16 +574,10 @@ export interface components {
/** Format: date-time */
updated_at: string;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
RunTrigger: "schedule" | "manual";
/** @description 已注册调度的任务及其下次触发时间。 */
ScheduledTaskResponse: {
/**
@@ -520,14 +638,14 @@ export interface components {
};
TaskResponse: {
action: components["schemas"]["TaskAction"];
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
/** Format: date-time */
created_at: string;
cron: string;
/** @description 一句话说明 */
description: string;
enabled: boolean;
id: string;
name: string;
retry_policy: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs: number;
timezone: string;
@@ -602,15 +720,66 @@ export interface components {
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
UpdateUvSettingsRequest: {
custom_path?: string | null;
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
extra_env?: {
[key: string]: string;
};
/** @description uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 */
mirror_url?: string | null;
source: components["schemas"]["UvSource"];
};
UvCandidate: {
available: boolean;
path: string;
source: components["schemas"]["UvSource"];
version?: string | null;
};
UvSettingsResponse: {
/** @description 实际生效的 uv 缓存目录(绝对路径) */
cache_dir: string;
/** @description 各来源的探测结果 */
candidates: components["schemas"]["UvCandidate"][];
custom_path?: string | null;
/** @description 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行 */
env_defaults: {
[key: string]: string;
};
/** @description 当前设置指向的 uv;不可用时 `version` 为空 */
executable?: string | null;
/** @description 额外注入给 uv 的环境变量;只允许 `UV_` 前缀 */
extra_env: {
[key: string]: string;
};
/** @description 程序目录下的 uv 目录 */
managed_dir: string;
/** @description 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认 */
mirror_url: string;
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
python_dir: string;
/** @description Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`) */
python_mirror_url: string;
source: components["schemas"]["UvSource"];
/** @description uv 自身压缩包的下载地址前缀 */
uv_download_url: string;
version?: string | null;
};
/**
* @description uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
* 会随启动方式变化,不适合当调度器的隐式依赖。
* @enum {string}
*/
UvSource: "managed" | "custom";
/** @description 编辑器在用户填写工作目录时发起的探测请求。 */
VenvProbeRequest: {
/** @description 解释器或可执行程序,对应 `action.interpreter` */
@@ -621,7 +790,7 @@ export interface components {
working_dir: string;
};
VenvProbeResponse: {
/** @description 没有虚拟环境时,是否具备用 uv 创建的条件 */
/** @description 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) */
can_create: boolean;
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
display_path?: string | null;
@@ -729,6 +898,121 @@ export interface operations {
};
};
};
list_envs: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Managed virtual environments */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ManagedEnvSummary"][];
};
};
};
};
create_env: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateManagedEnvRequest"];
};
};
responses: {
/** @description Virtual environment created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ManagedEnvSummary"];
};
};
/** @description Validation or uv failure */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete_env: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Environment name */
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Environment deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Environment not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
list_env_packages: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Environment name */
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Installed packages */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ManagedEnvPackage"][];
};
};
/** @description Environment not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
list_python_mirrors: {
parameters: {
query?: never;
@@ -939,6 +1223,68 @@ export interface operations {
};
};
};
list_pythons: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Pythons downloaded by uv into the platform directory */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ManagedPython"][];
};
};
/** @description uv unavailable */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
install_python: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["InstallPythonRequest"];
};
};
responses: {
/** @description Python downloaded; returns the installed list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ManagedPython"][];
};
};
/** @description Validation or uv failure */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
probe_python_venv: {
parameters: {
query?: never;
@@ -1209,6 +1555,8 @@ export interface operations {
query?: {
/** @description 绝对目录路径;留空表示列出盘符 */
path?: string;
/** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */
include_files?: boolean;
};
header?: never;
path?: never;
@@ -1216,7 +1564,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description Subdirectories of the requested path, or the drive list */
/** @description Subdirectories of the requested path (plus files when `include_files=true`), or the drive list */
200: {
headers: {
[name: string]: unknown;
@@ -1544,4 +1892,86 @@ export interface operations {
};
};
};
get_uv_settings: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description uv settings and detected executables */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UvSettingsResponse"];
};
};
};
};
update_uv_settings: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateUvSettingsRequest"];
};
};
responses: {
/** @description uv settings saved */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UvSettingsResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
download_uv: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description uv downloaded from the mirror and switched to it */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UvSettingsResponse"];
};
};
/** @description Download, verification or install failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
}
+60
View File
@@ -0,0 +1,60 @@
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
import { Brand, Icon, IconButton, toast } from '@stash/ui'
import { useEffect, useState } from 'react'
type Scheme = 'light' | 'dark'
const THEME_STORAGE_KEY = 'taskpilot.theme'
function readScheme(): Scheme {
const saved = window.localStorage.getItem(THEME_STORAGE_KEY)
return saved === 'dark' ? 'dark' : 'light'
}
/**
* 顶部栏:左边品牌、右边操作图标。
*
* 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。
* 明暗切换只写本机偏好(`<html data-scheme>`),刷新会重拉页面数据;
* 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。
*/
export function AppHeader() {
const queryClient = useQueryClient()
const [scheme, setScheme] = useState<Scheme>(readScheme)
const fetching = useIsFetching() > 0
useEffect(() => {
document.documentElement.dataset.scheme = scheme
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
}, [scheme])
return (
<div className="app-header">
<Brand name="TaskPilot" mark="TP" />
<div className="app-header-actions">
<IconButton
label={scheme === 'dark' ? '切换到浅色' : '切换到深色'}
icon={<Icon name={scheme === 'dark' ? 'sun_line' : 'moon_line'} />}
onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}
/>
<IconButton
label="刷新"
icon={<Icon name="lightning_line" />}
spinning={fetching}
onClick={() => void queryClient.invalidateQueries()}
/>
<IconButton
label="配置"
icon={<Icon name="settings_1_line" />}
onClick={() => toast('配置入口还在任务卡片里(uv / 镜像源 / 虚拟环境)')}
/>
<IconButton
label="退出"
icon={<Icon name="exit_line" />}
onClick={() => toast('本地单机版本没有登录,可以直接关掉窗口')}
/>
</div>
</div>
)
}
-72
View File
@@ -1,72 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { Brand, Icon, Menu, Tag, Text, type MenuItem } from '@stash/ui'
import { useLocation, useNavigate } from 'react-router-dom'
import { fetchHealth } from '../api/client'
const NAV_ITEMS: MenuItem[] = [
{
key: '/',
label: '任务调度',
icon: <Icon name="calendar_2_line" />,
},
{
key: '/mirrors',
label: 'Python 镜像',
icon: <Icon name="download_line" />,
},
]
/**
* 左侧导航:品牌 / 主导航 / 引擎状态。
*
* 页面本身只负责内容,导航与全局状态收在这里;窄屏时 Layout 会把侧栏
* 堆到内容上方,这里再用横向布局变形成顶部导航条。
*/
export function AppSidebar() {
const location = useLocation()
const navigate = useNavigate()
const activeKey = location.pathname === '/mirrors' ? '/mirrors' : '/'
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => fetchHealth(signal),
refetchInterval: 30_000,
})
const status = healthQuery.isPending
? { tone: 'neutral' as const, label: '检测中' }
: healthQuery.isError
? { tone: 'danger' as const, label: '引擎离线' }
: { tone: 'success' as const, label: '引擎在线' }
return (
<div className="app-sider">
<div className="app-sider-brand">
<Brand name="TaskPilot" mark="TP" />
<Text className="app-sider-tagline" size="xs" tone="muted">
本地任务调度 · Windows
</Text>
</div>
<Menu
className="app-nav"
items={NAV_ITEMS}
activeKey={activeKey}
onSelect={(key) => navigate(key)}
itemHeight={38}
/>
<div className="app-sider-foot">
<div className="app-sider-status">
<Tag tone={status.tone} dot>
{status.label}
</Tag>
<Text size="xs" tone="muted">
{healthQuery.data?.version
? `Rust 引擎 v${healthQuery.data.version}`
: '127.0.0.1:3000'}
</Text>
</div>
</div>
</div>
)
}
@@ -17,12 +17,25 @@ interface DirectoryPickerDialogProps {
initialPath: string
onSelect: (path: string) => void
onClose: () => void
/** directory:选目录(默认);file:选单个文件 */
mode?: 'directory' | 'file'
/** file 模式下只显示这些扩展名的文件,例如 ['.exe'];目录始终显示 */
fileExtensions?: string[]
/** 覆盖弹窗标题 */
title?: string
}
function isFilePath(value: string): boolean {
return /[^\\/]+\.[A-Za-z0-9]+$/.test(value.trim())
}
/**
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
*
* `mode="file"` 时同一套浏览逻辑也用来挑单个文件(例如 `uv.exe`):
* 目录可以继续进入,文件点击选中后点「使用此文件」。
*
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
* `Input` / `Button` / `Empty` / `Notice`。
*/
@@ -31,9 +44,14 @@ export function DirectoryPickerDialog({
initialPath,
onSelect,
onClose,
mode = 'directory',
fileExtensions,
title,
}: DirectoryPickerDialogProps) {
const isFileMode = mode === 'file'
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [draftPath, setDraftPath] = useState('')
const [selectedFile, setSelectedFile] = useState<string | null>(null)
useEffect(() => {
if (!open) {
@@ -42,11 +60,17 @@ export function DirectoryPickerDialog({
const start = initialPath.trim()
setCurrentPath(start || null)
setDraftPath(start)
setSelectedFile(null)
}, [initialPath, open])
const listing = useQuery({
queryKey: ['directories', currentPath],
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
queryKey: ['directories', currentPath, isFileMode],
queryFn: ({ signal }) =>
listDirectories(
currentPath ?? undefined,
{ includeFiles: isFileMode },
signal,
),
enabled: open,
retry: false,
})
@@ -57,20 +81,30 @@ export function DirectoryPickerDialog({
}
}, [listing.data?.path])
const entries = listing.data?.entries ?? []
const entries = (listing.data?.entries ?? []).filter((entry) => {
// 老接口条目没有 is_dir 时按目录处理,行为与之前一致。
if (!isFileMode || entry.is_dir !== false || !fileExtensions?.length) {
return true
}
const lower = entry.name.toLowerCase()
return fileExtensions.some((extension) => lower.endsWith(extension))
})
const currentDirectory = listing.data?.path ?? null
const parent = listing.data?.parent ?? null
const typedFile = isFileMode && isFilePath(draftPath) ? draftPath.trim() : null
const chosen = isFileMode ? (selectedFile ?? typedFile) : currentDirectory
const navigate = (path: string | null) => {
setCurrentPath(path)
setDraftPath(path ?? '')
setSelectedFile(null)
}
return (
<Modal
open={open}
onClose={onClose}
title="选择工作目录"
title={title ?? (isFileMode ? '选择文件' : '选择工作目录')}
size="lg"
className="directory-picker-modal"
footer={
@@ -80,22 +114,22 @@ export function DirectoryPickerDialog({
将使用
</Text>
<Text size="sm" tone="sub" truncate>
{currentDirectory ?? '尚未选择目录'}
{chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')}
</Text>
</div>
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
disabled={!currentDirectory}
disabled={!chosen}
onClick={() => {
if (currentDirectory) {
onSelect(currentDirectory)
if (chosen) {
onSelect(chosen)
onClose()
}
}}
>
使用此目录
{isFileMode ? '使用此文件' : '使用此目录'}
</Button>
</>
}
@@ -148,26 +182,49 @@ export function DirectoryPickerDialog({
{!listing.isPending && !listing.isError && entries.length === 0 ? (
<Empty
text={
currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'
currentDirectory
? isFileMode
? fileExtensions?.length
? `该目录下没有 ${fileExtensions.join(' / ')} 文件`
: '该目录下没有文件'
: '该目录下没有子文件夹'
: '没有可用的盘符'
}
/>
) : null}
{entries.length > 0 ? (
<ul className="directory-list">
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
className="directory-row"
onClick={() => navigate(entry.path)}
title={entry.path}
>
<Icon className="directory-icon" name="folder_more_line" />
<span className="directory-name">{entry.name}</span>
</button>
</li>
))}
{entries.map((entry) => {
const file = entry.is_dir === false
const selected = file && entry.path === selectedFile
return (
<li key={entry.path}>
<button
type="button"
className={
selected
? 'directory-row directory-row-selected'
: 'directory-row'
}
onClick={() =>
file ? setSelectedFile(entry.path) : navigate(entry.path)
}
title={entry.path}
>
<Icon
className="directory-icon"
name={file ? 'box_line' : 'folder_more_line'}
/>
<span className="directory-name">{entry.name}</span>
{selected ? (
<Icon className="directory-check" name="check_line" />
) : null}
</button>
</li>
)
})}
</ul>
) : null}
-32
View File
@@ -1,32 +0,0 @@
import { Space, Text, Title } from '@stash/ui'
import type { ReactNode } from 'react'
interface PageHeaderProps {
eyebrow: string
title: string
subtitle?: ReactNode
actions?: ReactNode
}
/** 页面标题区:左侧标题 / 说明,右侧操作。所有页面共用。 */
export function PageHeader({
eyebrow,
title,
subtitle,
actions,
}: PageHeaderProps) {
return (
<header className="page-header">
<div className="header-title">
<p className="eyebrow">{eyebrow}</p>
<Title level={1}>{title}</Title>
{subtitle !== undefined ? <Text tone="sub">{subtitle}</Text> : null}
</div>
{actions !== undefined ? (
<Space gap="sm" wrap>
{actions}
</Space>
) : null}
</header>
)
}
@@ -0,0 +1,343 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Empty,
FormField,
Input,
Modal,
Notice,
Result,
Select,
Space,
Spinner,
Table,
Tag,
Text,
Textarea,
TextButton,
toast,
type TableColumn,
} from '@stash/ui'
import { useState } from 'react'
import {
createManagedEnv,
deleteManagedEnv,
listManagedEnvPackages,
listManagedEnvs,
listPythons,
type ManagedEnv,
} from '../api/client'
import { ConfirmDialog } from './ConfirmDialog'
const ENV_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称', width: 200 },
{ key: 'python_version', title: 'Python', width: 120 },
{ key: 'path', title: '路径', width: 380 },
{ key: 'actions', title: '操作', align: 'right', width: 180 },
]
const PACKAGE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '包', width: 320 },
{ key: 'version', title: '版本', width: 160 },
]
function linesToPackages(value: string): string[] {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
/**
* 「Python 环境」页的托管虚拟环境区块。
*
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
*
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
*/
export function PythonEnvsSection() {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [pythonVersion, setPythonVersion] = useState('')
const [packagesText, setPackagesText] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ManagedEnv | null>(null)
const envsQuery = useQuery({
queryKey: ['managed-envs'],
queryFn: ({ signal }) => listManagedEnvs(signal),
})
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
})
const pythonOptions = [
{ value: '', label: '默认(uv 自动选)' },
...(pythonsQuery.data ?? []).map((python) => ({
value: python.version,
label: python.version,
})),
]
const packagesQuery = useQuery({
queryKey: ['managed-env-packages', selected],
queryFn: ({ signal }) => listManagedEnvPackages(selected ?? '', signal),
enabled: Boolean(selected),
})
const createMutation = useMutation({
mutationFn: () =>
createManagedEnv({
name: name.trim(),
python_version: pythonVersion.trim() || null,
packages: linesToPackages(packagesText),
}),
onSuccess: (env: ManagedEnv) => {
setCreateOpen(false)
setName('')
setPythonVersion('')
setPackagesText('')
toast.success(
env.python_version
? `已创建环境 ${env.name}(Python ${env.python_version})`
: `已创建环境 ${env.name}`,
)
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
},
onError: (error) => toast.error(error.message),
})
const deleteMutation = useMutation({
mutationFn: (env: ManagedEnv) => deleteManagedEnv(env.name),
onSuccess: (_data, env) => {
setDeleteTarget(null)
if (selected === env.name) {
setSelected(null)
}
toast.success(`已删除环境 ${env.name}`)
void queryClient.invalidateQueries({ queryKey: ['managed-envs'] })
},
onError: (error) => toast.error(error.message),
})
const envs = envsQuery.data ?? []
const rows = envs.map((env) => ({
id: env.name,
name: <Text strong>{env.name}</Text>,
python_version: env.python_version ? (
<Tag shape="rounded">{env.python_version}</Tag>
) : (
<Text size="sm" tone="muted">
—
</Text>
),
path: (
<code className="code-chip" title={env.path}>
{env.path}
</code>
),
actions: (
<Space justify="end" gap="xs" wrap>
<TextButton onClick={() => setSelected(env.name)}>查看包</TextButton>
<TextButton tone="danger" onClick={() => setDeleteTarget(env)}>
删除
</TextButton>
</Space>
),
}))
return (
<>
<Card padding="md">
<div className="env-manager-head">
<Space direction="column" gap="xs" align="start">
<Text strong>虚拟环境</Text>
<Text size="xs" tone="muted">
用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
</Text>
</Space>
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
</div>
</Card>
<Card padding="none">
{envsQuery.isError ? (
<Result
status="error"
title="虚拟环境列表加载失败"
description={envsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void envsQuery.refetch()}
>
重试
</Button>
}
/>
) : envsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取虚拟环境" />
<Text tone="sub">正在读取虚拟环境...</Text>
</div>
) : envs.length === 0 ? (
<Empty
className="env-manager-empty"
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
/>
) : (
<Table columns={ENV_COLUMNS} rows={rows} />
)}
</Card>
{selected ? (
<Card padding="none">
<div className="env-packages-head">
<Text strong>{selected} 已安装的包</Text>
<Space gap="xs" align="center">
<Text size="xs" tone="muted">
共 {packagesQuery.data?.length ?? 0} 个
</Text>
<TextButton onClick={() => setSelected(null)}>收起</TextButton>
</Space>
</div>
{packagesQuery.isError ? (
<Result
status="error"
title="包列表加载失败"
description={packagesQuery.error.message}
/>
) : packagesQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取已安装的包" />
</div>
) : (packagesQuery.data?.length ?? 0) === 0 ? (
<Empty className="empty-state" text="这个环境里没有安装第三方包" />
) : (
<Table
columns={PACKAGE_COLUMNS}
rows={packagesQuery.data.map((item) => ({
id: item.name,
name: <Text className="mono">{item.name}</Text>,
version: <Text className="mono">{item.version}</Text>,
}))}
/>
)}
</Card>
) : null}
<Notice title="环境创建后不会自动更新">
「新建环境」会用 uv 建一个空环境并按需预装包(需要网络);之后不会自动升级依赖。
运行任务不会改动这些环境。
</Notice>
<Modal
open={createOpen}
onClose={() => {
if (!createMutation.isPending) {
setCreateOpen(false)
}
}}
title="新建虚拟环境"
size="md"
closeOnOverlay={!createMutation.isPending}
footer={
<>
<Button
variant="secondary"
onClick={() => setCreateOpen(false)}
disabled={createMutation.isPending}
>
取消
</Button>
<Button
type="submit"
form="create-env-form"
loading={createMutation.isPending}
>
创建
</Button>
</>
}
>
<form
id="create-env-form"
className="mirror-form"
noValidate
onSubmit={(event) => {
event.preventDefault()
if (!name.trim()) {
toast.error('请填写环境名')
return
}
createMutation.mutate()
}}
>
<FormField
label="环境名"
htmlFor="env-name"
hint="只能包含字母、数字、点、下划线、连字符;同时是 data\envs 下的目录名"
>
<Input
id="env-name"
value={name}
onChange={(event) => setName(event.target.value)}
className="mono-input"
placeholder="data-science"
spellCheck={false}
/>
</FormField>
<FormField
label="Python 版本(可选)"
hint={
(pythonsQuery.data?.length ?? 0) === 0
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
}
>
<Select
options={pythonOptions}
value={pythonVersion}
onChange={setPythonVersion}
/>
</FormField>
<FormField
label="预装包(可选)"
htmlFor="env-packages"
hint="每行一个,可带版本,例如 numpy==2.1.0"
>
<Textarea
id="env-packages"
value={packagesText}
onChange={(event) => setPackagesText(event.target.value)}
rows={5}
placeholder={'numpy==2.1.0\npandas\nrequests'}
spellCheck={false}
/>
</FormField>
</form>
</Modal>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除环境 ${deleteTarget?.name ?? ''}`}
description="会直接删除 data\envs 下的整个环境目录,里面安装的包一并删除;正在使用它的任务下次运行会失败。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</>
)
}
@@ -11,7 +11,6 @@ import {
Tag,
Text,
TextButton,
Title,
toast,
type TableColumn,
} from '@stash/ui'
@@ -24,12 +23,11 @@ import {
updatePythonMirror,
type PythonMirror,
} from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog'
import { PageHeader } from '../components/PageHeader'
import { ConfirmDialog } from './ConfirmDialog'
import {
PythonMirrorModal,
type PythonMirrorFormValues,
} from '../components/PythonMirrorModal'
} from './PythonMirrorModal'
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称', width: 180 },
@@ -49,12 +47,12 @@ function toRequest(values: PythonMirrorFormValues) {
}
/**
* Python 包镜像管理。
* 「Python 环境」页的包索引镜像区块。
*
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
*/
export function PythonMirrorsPage() {
export function PythonMirrorsSection() {
const queryClient = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
@@ -106,7 +104,6 @@ export function PythonMirrorsPage() {
})
const mirrors = mirrorsQuery.data ?? []
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
const openCreate = () => {
setEditingMirror(null)
@@ -166,38 +163,17 @@ export function PythonMirrorsPage() {
}))
return (
<div className="page-stack">
<PageHeader
eyebrow="PYTHON / PACKAGE MIRROR"
title="Python 镜像"
subtitle="管理 pip / uv 的包索引;默认镜像会在脚本任务运行时注入环境变量。"
actions={<Button onClick={openCreate}>+ 新增镜像</Button>}
/>
<Card padding="md" className="mirror-summary">
<div className="mirror-summary-main">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Title level={2}>
{defaultMirror?.name ?? '未设置默认镜像'}
</Title>
<Text className="mono" size="sm" tone="sub">
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
</Text>
</div>
<div className="mirror-summary-meta">
<Text size="xs" tone="muted">
运行时注入
</Text>
<Space gap="xs" wrap>
<Tag shape="rounded">PIP_INDEX_URL</Tag>
<Tag shape="rounded">UV_INDEX_URL</Tag>
{defaultMirror?.trusted_host ? (
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
) : null}
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs" align="start">
<Text strong>包索引镜像</Text>
<Text size="xs" tone="muted">
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
</Text>
</Space>
</div>
<Button onClick={openCreate}>+ 新增镜像</Button>
</Space>
</Card>
<Notice title="镜像只影响任务运行,不会自动安装依赖">
@@ -270,6 +246,6 @@ export function PythonMirrorsPage() {
}
}}
/>
</div>
</>
)
}
-1
View File
@@ -53,7 +53,6 @@ const TRIGGER_FILTERS = [
{ value: '', label: '全部触发' },
{ value: 'schedule', label: '定时' },
{ value: 'manual', label: '手动' },
{ value: 'retry', label: '重试' },
]
/**
+8 -2
View File
@@ -30,14 +30,20 @@ export function RunStatusBadge({ status }: RunStatusBadgeProps) {
interface EnabledBadgeProps {
enabled: boolean
className?: string
}
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
export function EnabledBadge({ enabled, className }: EnabledBadgeProps) {
return (
<Tag
tone={enabled ? 'success' : 'neutral'}
dot
className={enabled ? 'status-enabled' : 'status-disabled'}
className={[
enabled ? 'status-enabled' : 'status-disabled',
className,
]
.filter(Boolean)
.join(' ')}
>
{enabled ? '活跃中' : '已暂停'}
</Tag>
+175
View File
@@ -0,0 +1,175 @@
import { Button, Divider, Modal, Space, Tag, Text } from '@stash/ui'
import type { ReactNode } from 'react'
import type { Task } from '../api/client'
import {
actionTypeLabel,
formatDateTime,
formatShortDateTime,
} from '../lib/format'
import { EnabledBadge } from './StatusBadge'
interface TaskDetailModalProps {
task: Task | null
nextRunAt?: string
onClose: () => void
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<Text size="xs" tone="muted">
{label}
</Text>
<div className="detail-value">{children}</div>
</div>
)
}
function envSummary(env: Record<string, string> | undefined): string {
const entries = Object.entries(env ?? {})
if (entries.length === 0) {
return '无'
}
return entries.map(([key, value]) => `${key}=${value}`).join('\n')
}
/**
* 任务详情:只读展示任务配置,入口是任务卡片上的「详情」。
*
* 和编辑表单分开:这里不做校验,也不会因为字段缺失而崩,方便核对线上实际配置。
*/
export function TaskDetailModal({
task,
nextRunAt,
onClose,
}: TaskDetailModalProps) {
return (
<Modal
open={task !== null}
onClose={onClose}
title="任务详情"
size="lg"
footer={
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
}
>
{task ? (
<Space direction="column" gap="md" align="stretch">
<Space direction="column" gap="xs" align="start">
<Space gap="xs" align="center" wrap>
<Text size="lg" strong>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
<EnabledBadge enabled={task.enabled} />
</Space>
<Text className="mono" size="xs" tone="muted">
{task.id}
</Text>
{task.description ? (
<Text size="sm" tone="sub">
{task.description}
</Text>
) : null}
</Space>
<Divider spacing="sm" />
<Text strong size="sm">
基本信息
</Text>
<div className="detail-grid">
<DetailRow label="Cron">
<code className="code-chip">{task.cron}</code>
</DetailRow>
<DetailRow label="时区">{task.timezone}</DetailRow>
<DetailRow label="下次触发">
{task.enabled
? nextRunAt
? formatShortDateTime(nextRunAt)
: '调度中…'
: '未调度'}
</DetailRow>
<DetailRow label="超时">{task.timeout_secs} 秒</DetailRow>
</div>
<Divider spacing="sm" />
<Text strong size="sm">
执行配置
</Text>
<div className="detail-grid">
{task.action.type === 'script' ? (
<>
<DetailRow label="工作目录">
{task.action.working_dir}
</DetailRow>
<DetailRow label="解释器">{task.action.interpreter}</DetailRow>
<DetailRow label="脚本">{task.action.script}</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
{task.action.type === 'http' ? (
<>
<DetailRow label="请求 URL">{task.action.url}</DetailRow>
<DetailRow label="请求方式">{task.action.method}</DetailRow>
<DetailRow label="期望状态码">
{task.action.expected_status.length > 0
? task.action.expected_status.join(', ')
: '2xx'}
</DetailRow>
<DetailRow label="请求 Header">
{envSummary(task.action.headers)}
</DetailRow>
<DetailRow label="Body">{task.action.body || '无'}</DetailRow>
</>
) : null}
{task.action.type === 'process' ? (
<>
<DetailRow label="可执行文件">
{task.action.executable}
</DetailRow>
<DetailRow label="工作目录">
{task.action.working_dir ?? '继承程序目录'}
</DetailRow>
<DetailRow label="运行参数">
{task.action.args.length > 0
? task.action.args.join(' ')
: '无'}
</DetailRow>
<DetailRow label="环境变量">
{envSummary(task.action.env)}
</DetailRow>
</>
) : null}
</div>
<Divider spacing="sm" />
<Text strong size="sm">
时间
</Text>
<div className="detail-grid">
<DetailRow label="创建时间">
{formatDateTime(task.created_at)}
</DetailRow>
<DetailRow label="更新时间">
{formatDateTime(task.updated_at)}
</DetailRow>
</div>
</Space>
) : null}
</Modal>
)
}
+61 -5
View File
@@ -96,6 +96,8 @@ export function TaskEditorModal({
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const [pickerOpen, setPickerOpen] = useState(false)
// 新建 Python 任务时先跑一次 uv 建环境,这期间不允许关闭弹窗。
const [preparingVenv, setPreparingVenv] = useState(false)
const queryClient = useQueryClient()
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
@@ -338,8 +340,10 @@ export function TaskEditorModal({
document.getElementById(task ? 'task-name' : 'task-id')?.focus()
}, [open, task])
const busy = saving || preparingVenv
const requestClose = () => {
if (!saving) {
if (!busy) {
onClose()
}
}
@@ -347,6 +351,39 @@ export function TaskEditorModal({
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
// 新建 Python 任务时先把环境准备好:环境问题当场暴露,不用等第一次运行。
if (
!task &&
values.actionType === 'script' &&
values.script.language === 'python'
) {
const workingDir = values.script.working_dir.trim()
if (workingDir) {
setPreparingVenv(true)
try {
const probe = await probePythonVenv({
working_dir: workingDir,
interpreter: values.script.interpreter.trim() || 'python.exe',
venv: values.script.venv.trim() || null,
})
if (probe.can_create) {
const created = await createPythonVenv({
working_dir: workingDir,
})
toast.success(
created.venv_path
? `已创建虚拟环境:${created.venv_path}`
: '虚拟环境已创建',
)
void queryClient.invalidateQueries({
queryKey: ['python-venv'],
})
}
} finally {
setPreparingVenv(false)
}
}
}
await onSubmit(values)
} catch (error) {
if (error instanceof ApiRequestError && error.field) {
@@ -372,14 +409,18 @@ export function TaskEditorModal({
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
size="lg"
className="task-editor-modal"
closeOnOverlay={!saving}
closeOnOverlay={!busy}
footer={
<>
<Button variant="secondary" onClick={requestClose} disabled={saving}>
<Button variant="secondary" onClick={requestClose} disabled={busy}>
取消
</Button>
<Button type="submit" form="task-editor-form" loading={saving}>
{task ? '保存修改' : '保存并热刷新'}
<Button type="submit" form="task-editor-form" loading={busy}>
{preparingVenv
? '正在创建虚拟环境…'
: task
? '保存修改'
: '保存并热刷新'}
</Button>
</>
}
@@ -421,6 +462,21 @@ export function TaskEditorModal({
</FormField>
</div>
<FormField
label="任务说明"
htmlFor="task-description"
hint="一句话说明这个任务做什么,会显示在任务卡片和详情里"
error={errors.description?.message}
>
<Textarea
id="task-description"
{...register('description')}
rows={2}
placeholder="例如:定时请求核心 API,返回非 200 状态码时触发报警"
aria-invalid={Boolean(errors.description)}
/>
</FormField>
<div className="form-grid">
<FormField
label="Cron 表达式"
+601
View File
@@ -0,0 +1,601 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Empty,
FormField,
Icon,
Input,
Modal,
Notice,
RadioGroup,
Result,
Space,
Spinner,
Tabs,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
installPython,
listPythons,
updateUvSettings,
type ManagedPython,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
const PYTHON_VERSION_HINT = '3.12'
type UvTab = 'binary' | 'env' | 'python'
interface EnvRow {
name: string
value: string
}
function rowsFrom(
extraEnv: Record<string, string> | null | undefined,
envDefaults: Record<string, string> | null | undefined,
): EnvRow[] {
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
name,
value,
}))
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改值;
// 这两个变量决定解释器和缓存的位置,界面里不允许删除。
for (const [name, value] of Object.entries(envDefaults ?? {})) {
const configured = rows.some(
(row) => row.name.toUpperCase() === name.toUpperCase(),
)
if (!configured) {
rows.unshift({ name, value })
}
}
return rows
}
function envMap(rows: EnvRow[]): Record<string, string> {
const map: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name && !value) {
continue
}
map[name] = value
}
return map
}
/** `D:\tools\uv.exe` → `D:\tools`;拿不到目录时返回空串(让浏览器从盘符开始)。 */
function directoryOf(path: string): string {
const trimmed = path.trim()
const index = Math.max(
trimmed.lastIndexOf('\\'),
trimmed.lastIndexOf('/'),
)
if (index <= 0) {
return ''
}
const directory = trimmed.slice(0, index)
return directory.endsWith(':') ? `${directory}\\` : directory
}
interface UvSettingsModalProps {
open: boolean
onClose: () => void
}
/**
* uv 配置弹窗:三段配置用标签页切换——可执行文件 / `UV_*` 环境变量 / Python 解释器。
*
* - 来源二选一:程序目录(只读路径 + 缺失时「下载 uv」)或指定路径(可编辑 + 「浏览」挑 exe)。
* - 下载镜像也在这一步:uv 本体与解释器都从它派生;解释器还可以用
* `UV_PYTHON_INSTALL_MIRROR` 单独覆盖(它就在环境变量里)。
* - 环境变量里 `UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_MIRROR`
* 是 uv 的关键变量,只能改值、不能删除;其余 `UV_*` 变量照常增删。
* - 解释器由 uv 下载到 `UV_PYTHON_INSTALL_DIR`;虚拟环境弹窗只从这份列表里挑版本。
* - 顶部不再放状态汇总:版本、解释器数量、镜像地址在首页的 uv 卡片里已经有了。
*/
export function UvSettingsModal({ open, onClose }: UvSettingsModalProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
// 解释器列表由 uv 自己列举;uv 不可用时接口会报错,先不请求。
const uvReady = Boolean(settingsQuery.data?.version)
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: uvReady,
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftMirror, setDraftMirror] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const [pythonVersion, setPythonVersion] = useState('')
const [pickerOpen, setPickerOpen] = useState(false)
const [activeTab, setActiveTab] = useState<UvTab>('binary')
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const mirrorUrl = draftMirror ?? settings?.mirror_url ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const pythons = pythonsQuery.data ?? []
const managedPath = `${settings?.managed_dir ?? 'uv'}\\uv.exe`
const lockedNames = Object.keys(settings?.env_defaults ?? {}).map((name) =>
name.toUpperCase(),
)
const isLockedName = (name: string) =>
lockedNames.includes(name.trim().toUpperCase())
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(null)
setDraftMirror(null)
setDraftEnv(null)
}
const saveMutation = useMutation({
mutationFn: () => {
const extraEnv = envMap(envRows)
// 和默认值一样就不写死,删掉等于回到默认。
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
const configured = Object.keys(extraEnv).some(
(key) => key.toUpperCase() === name.toUpperCase(),
)
if (configured && extraEnv[name]?.trim() === value) {
delete extraEnv[name]
}
}
return updateUvSettings({
source,
custom_path: customPath.trim() || null,
extra_env: extraEnv,
mirror_url: mirrorUrl.trim() || null,
})
},
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
saved.version
? `uv 设置已保存(${shortUvVersion(saved.version)})`
: '已保存 uv 设置,但当前 uv 不可用',
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const downloadMutation = useMutation({
mutationFn: () => downloadUv(),
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: pythonVersion.trim() }),
onSuccess: (installed: ManagedPython[]) => {
setPythonVersion('')
queryClient.setQueryData(['managed-pythons'], installed)
toast.success(`Python 已下载(当前 ${installed.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const submit = () => {
const seen = new Set<string>()
for (const row of envRows) {
const name = row.name.trim()
if (!name) {
if (row.value.trim()) {
toast.error('环境变量名不能为空')
return
}
continue
}
if (!name.toUpperCase().startsWith('UV_')) {
toast.error(`环境变量名 ${name} 需要以 UV_ 开头`)
return
}
const key = name.toUpperCase()
if (seen.has(key)) {
toast.error(`环境变量 ${name} 重复`)
return
}
seen.add(key)
}
// 两个路径变量决定解释器与缓存的位置,清空会让 uv 找不到目录。
for (const name of lockedNames) {
const row = envRows.find(
(candidate) => candidate.name.trim().toUpperCase() === name,
)
if (!row || !row.value.trim()) {
toast.error(`${name} 不能为空,它决定 uv 的 Python / 缓存目录`)
return
}
}
saveMutation.mutate()
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Modal
open={open}
onClose={onClose}
title="uv 配置"
size="lg"
className="uv-settings-modal"
footer={
<>
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
<Button
type="submit"
form="uv-settings-form"
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存设置
</Button>
</>
}
>
{settingsQuery.isError ? (
<Result
status="error"
title="uv 设置加载失败"
description={settingsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void settingsQuery.refetch()}
>
重试
</Button>
}
/>
) : settingsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 uv 设置" />
<Text tone="sub">正在检测 uv...</Text>
</div>
) : (
<form
id="uv-settings-form"
className="uv-settings"
noValidate
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Tabs
className="uv-tabs"
variant="line"
items={[
{ key: 'binary', label: 'uv 可执行文件' },
{ key: 'env', label: `环境变量(${envRows.length})` },
{ key: 'python', label: `Python 解释器(${pythons.length})` },
]}
activeKey={activeTab}
onChange={(key) => setActiveTab(key as UvTab)}
/>
{activeTab === 'binary' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="uv 可执行文件设置"
>
<RadioGroup
direction="row"
options={SOURCE_OPTIONS}
value={source}
onChange={(value) => setDraftSource(value as UvSource)}
/>
<div className="uv-path-row">
{source === 'managed' ? (
// 程序目录的 uv 是固定位置,只展示、不可编辑;只有「指定路径」能改。
<div className="uv-path-static" title={managedPath}>
<Icon name="lock_line" />
<span className="uv-path-static-text">{managedPath}</span>
</div>
) : (
<Input
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
aria-label="uv 可执行文件"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
)}
{source === 'managed' ? (
uvReady ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)
) : (
<Button
size="sm"
variant="secondary"
onClick={() => setPickerOpen(true)}
>
浏览
</Button>
)}
</div>
<Text size="xs" tone="muted">
{source === 'managed'
? uvReady
? '程序目录里的 uv,随发布目录整体搬走。'
: '没装 uv 时可以从下面的镜像下载当前架构的版本,校验 sha256 后放进程序目录并自动切换。'
: '填 uv.exe 的完整路径,或点「浏览」选择;保存时会校验它能否运行。'}
</Text>
<FormField
label="下载镜像"
htmlFor="uv-mirror-url"
hint="GitHub Release 镜像根:uv 本体与 Python 解释器都从这里下载,留空用内置默认。解释器地址也可以用下面的 UV_PYTHON_INSTALL_MIRROR 单独覆盖。"
>
<Input
id="uv-mirror-url"
value={mirrorUrl}
onChange={(event) => setDraftMirror(event.target.value)}
className="mono-input"
placeholder="https://mirrors.ustc.edu.cn/github-release"
spellCheck={false}
/>
</FormField>
</section>
) : activeTab === 'env' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="UV 环境变量"
>
<div className="uv-panel-head">
<Text size="xs" tone="muted">
共 {envRows.length} 项
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</div>
<Text size="xs" tone="muted">
UV_PYTHON_INSTALL_DIR、UV_CACHE_DIR 决定解释器和缓存放在哪,
UV_PYTHON_INSTALL_MIRROR 决定解释器从哪下载;这三项只能改值、不能删除。
其余 UV_* 变量会注入到每一次 uv 调用,路径类可填相对程序目录的值。
</Text>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => {
const locked = isLockedName(row.name)
const isMirror =
row.name.trim().toUpperCase() ===
'UV_PYTHON_INSTALL_MIRROR'
return (
<div className="uv-env-row" key={index}>
{locked ? (
<span
className="uv-env-locked"
title={
isMirror
? '解释器的下载源,不能删除;改值即可换镜像'
: '决定 uv 的 Python / 缓存目录,不能删除'
}
>
<Icon name="lock_line" />
<span className="uv-env-locked-name">{row.name}</span>
</span>
) : (
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_HTTP_TIMEOUT"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
)}
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder={
locked && !isMirror
? '相对于程序目录,或绝对路径'
: '值'
}
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
{locked ? (
<Text size="xs" tone="muted">
固定
</Text>
) : (
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter(
(_, position) => position !== index,
),
)
}
>
删除
</TextButton>
)}
</div>
)
})}
</div>
)}
</section>
) : (
<section
className="uv-section"
role="tabpanel"
aria-label="Python 解释器设置"
>
<Text size="xs" tone="muted">
由 uv 下载,共 {pythons.length} 个版本。解释器放在{' '}
<Text className="mono" size="xs" tone="sub">
{settings?.python_dir ?? 'python\\'}
</Text>
(UV_PYTHON_INSTALL_DIR);新建虚拟环境时直接选这里的版本,不用重复下载。
</Text>
<Space gap="xs" align="center" wrap>
<Input
value={pythonVersion}
onChange={(event) => setPythonVersion(event.target.value)}
className="mono-input uv-version-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
disabled={!uvReady}
title={uvReady ? undefined : '请先安装或指定 uv'}
onClick={() => {
if (!pythonVersion.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Notice
tone="error"
title="解释器列表读取失败"
action={{
label: '重试',
onClick: () => void pythonsQuery.refetch(),
}}
>
{pythonsQuery.error.message}
</Notice>
) : pythonsQuery.isPending ? (
uvReady ? (
<div className="uv-inline-state">
<Spinner size="sm" label="正在读取解释器" />
<Text size="sm" tone="muted">
正在读取解释器...
</Text>
</div>
) : (
<Text size="sm" tone="muted">
先在上面安装或指定 uv,装好之后才能下载解释器。
</Text>
)
) : pythons.length === 0 ? (
<Empty
className="uv-empty"
text="还没有下载解释器。填一个版本号(例如 3.12)后点「下载 Python」。"
/>
) : (
<ul className="uv-python-list">
{pythons.map((python) => (
<li className="uv-python-item" key={python.version}>
<Text strong className="uv-python-version">
{python.version}
</Text>
<code
className="code-chip uv-python-path"
title={python.path}
>
{python.path}
</code>
</li>
))}
</ul>
)}
</section>
)}
</form>
)}
<DirectoryPickerDialog
open={pickerOpen}
initialPath={directoryOf(customPath)}
mode="file"
fileExtensions={['.exe']}
title="选择 uv 可执行文件"
onSelect={(path) => setDraftCustomPath(path)}
onClose={() => setPickerOpen(false)}
/>
</Modal>
)
}
+11
View File
@@ -53,6 +53,17 @@ export function actionTypeLabel(type: TaskActionType): string {
}[type]
}
/** uv --version 是 `uv 0.13.7 (hash 日期 架构)`,界面只显示版本号。 */
export function shortUvVersion(
version: string | null | undefined,
): string | undefined {
if (!version) {
return undefined
}
const number = version.replace(/^uv\s+/i, '').split('(')[0]?.trim()
return number || version
}
export const runStatusLabels: Record<RunStatus, string> = {
queued: '排队中',
running: '运行中',
+9 -6
View File
@@ -31,7 +31,7 @@ export const SCRIPT_LANGUAGE_META: Record<
defaultScript: 'main.py',
scriptLabel: '脚本文件或相对路径',
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
hint: '自动使用项目 .venv;没有环境时可在下方用 uv 创建。',
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
},
shell: {
label: 'Shell (cmd)',
@@ -84,6 +84,7 @@ export function detectScriptLanguage(interpreter: string): ScriptLanguage {
export interface TaskFormValues {
id: string
name: string
description: string
cron: string
timezone: string
timeout_secs: number
@@ -142,6 +143,10 @@ export const taskFormSchema = z
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
description: z
.string()
.trim()
.max(500, '任务说明不能超过 500 个字符'),
cron: z
.string()
.trim()
@@ -227,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
description: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
@@ -261,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.description = task.description ?? ''
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
@@ -359,15 +366,11 @@ export function valuesToUpdateRequest(
): UpdateTaskRequest {
return {
name: values.name.trim(),
description: values.description.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
+581 -279
View File
@@ -5,24 +5,22 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import {
Avatar,
Button,
Card,
Empty,
Grid,
Modal,
Notice,
Pagination,
Result,
SearchField,
Select,
Space,
Spinner,
Table,
Tag,
Text,
TextButton,
Title,
toast,
type TableColumn,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
@@ -32,6 +30,10 @@ import {
fetchHealth,
fetchSchedule,
fetchStats,
getUvSettings,
listManagedEnvs,
listPythons,
listPythonMirrors,
listTasks,
runTask,
setTaskEnabled,
@@ -41,11 +43,18 @@ import {
type TaskFilters,
} from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog'
import { PageHeader } from '../components/PageHeader'
import { PythonEnvsSection } from '../components/PythonEnvsSection'
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
import { RunLogDrawer } from '../components/RunLogDrawer'
import { EnabledBadge } from '../components/StatusBadge'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { TaskEditorModal } from '../components/TaskEditorModal'
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
import { UvSettingsModal } from '../components/UvSettingsModal'
import {
actionTypeLabel,
formatShortDateTime,
shortUvVersion,
} from '../lib/format'
import { useDebouncedValue } from '../lib/useDebouncedValue'
import {
type TaskFormValues,
@@ -53,7 +62,7 @@ import {
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
const MAX_TASKS = 100
const ENABLED_OPTIONS = [
{ value: '', label: '全部状态' },
@@ -68,14 +77,12 @@ const ACTION_TYPE_OPTIONS = [
{ value: 'process', label: '进程' },
]
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称 / 任务标识', width: 230 },
{ key: 'timezone', title: '时区', width: 110 },
{ key: 'schedule', title: '调度周期', width: 200 },
{ key: 'status', title: '状态', width: 100 },
{ key: 'timeout', title: '超时', width: 80 },
{ key: 'actions', title: '操作', align: 'right', width: 240 },
]
/// 列表按任务类型排在一起,类型在卡片里用标签标出。
const TYPE_ORDER: Record<TaskActionType, number> = {
script: 0,
http: 1,
process: 2,
}
export function TaskDashboard() {
const queryClient = useQueryClient()
@@ -83,14 +90,16 @@ export function TaskDashboard() {
const q = searchParams.get('q') ?? ''
const enabledValue = searchParams.get('enabled')
const actionTypeValue = searchParams.get('action_type')
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
const [editorOpen, setEditorOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
const [logTask, setLogTask] = useState<Task | null>(null)
const [detailTask, setDetailTask] = useState<Task | null>(null)
const [initialRunId, setInitialRunId] = useState<number | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [uvSettingsOpen, setUvSettingsOpen] = useState(false)
const [mirrorSettingsOpen, setMirrorSettingsOpen] = useState(false)
const [envManagerOpen, setEnvManagerOpen] = useState(false)
const [queryDraft, setQueryDraft] = useState(q)
const debouncedQuery = useDebouncedValue(queryDraft, 300)
@@ -108,8 +117,8 @@ export function TaskDashboard() {
q: q || undefined,
enabled,
action_type: actionType,
page,
page_size: PAGE_SIZE,
page: 1,
page_size: MAX_TASKS,
}
const healthQuery = useQuery({
@@ -132,9 +141,32 @@ export function TaskDashboard() {
queryFn: ({ signal }) => fetchSchedule(signal),
refetchInterval: 15_000,
})
const uvQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
const mirrorsQuery = useQuery({
queryKey: ['python-mirrors'],
queryFn: ({ signal }) => listPythonMirrors(signal),
})
const envsQuery = useQuery({
queryKey: ['managed-envs'],
queryFn: ({ signal }) => listManagedEnvs(signal),
})
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: Boolean(uvQuery.data?.version),
})
const nextRuns = new Map(
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
)
const defaultMirror =
mirrorsQuery.data?.find((mirror) => mirror.is_default) ?? null
const uv = uvQuery.data
const managedEnvs = envsQuery.data ?? []
const pythonCount = pythonsQuery.data?.length ?? 0
const saveMutation = useMutation({
mutationFn: ({
@@ -200,14 +232,25 @@ export function TaskDashboard() {
if (nextQuery === q) {
return
}
updateFilters({ q: nextQuery || undefined, page: '1' })
updateFilters({ q: nextQuery || undefined })
// updateFilters 依赖当前 searchParams,每次渲染都是新的闭包;这里只跟随防抖后的输入。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery])
const tasks = tasksQuery.data?.items ?? []
const total = tasksQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const sortedTasks = [...tasks].sort(
(left, right) =>
TYPE_ORDER[left.action.type] - TYPE_ORDER[right.action.type],
)
// 最近一次触发:取所有已注册调度里最早的 next_run_at,连带任务名一起显示
const nearest = (scheduleQuery.data ?? [])
.filter((entry): entry is { task_id: string; next_run_at: string } =>
Boolean(entry.next_run_at),
)
.sort((left, right) => left.next_run_at.localeCompare(right.next_run_at))[0]
const nearestTask = nearest
? tasks.find((task) => task.id === nearest.task_id)
: undefined
function updateFilters(changes: Record<string, string | undefined>) {
const next = new URLSearchParams(searchParams)
@@ -231,259 +274,419 @@ export function TaskDashboard() {
setEditorOpen(true)
}
const refreshAll = async () => {
if (refreshing) {
return
}
setRefreshing(true)
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
queryClient.invalidateQueries({ queryKey: ['health'] }),
])
} finally {
setRefreshing(false)
}
}
const rows = tasks.map((task) => {
const isRunning =
runMutation.isPending && runMutation.variables?.id === task.id
const isToggling =
toggleMutation.isPending && toggleMutation.variables?.id === task.id
return {
id: task.id,
name: (
<div className="task-name-cell">
<div className="task-name-row">
<Text className="task-name" strong>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
</div>
<Text className="task-id" size="xs" tone="muted">
{task.id}
</Text>
</div>
),
schedule: (
<div className="schedule-cell">
<code className="code-chip">{task.cron}</code>
<Text size="xs" tone="muted">
{task.enabled
? nextRuns.get(task.id)
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
: '调度中…'
: '未调度'}
</Text>
</div>
),
timezone: (
<Text className="timezone-cell" size="sm" tone="sub">
{task.timezone}
</Text>
),
status: <EnabledBadge enabled={task.enabled} />,
timeout: (
<Text size="sm" tone="sub">
{task.timeout_secs}s
</Text>
),
actions: (
<Space justify="end" gap="xs" wrap>
<TextButton
disabled={isRunning}
title="立即运行"
onClick={() => runMutation.mutate(task)}
>
{isRunning ? '提交中' : '运行'}
</TextButton>
<TextButton
onClick={() => {
setLogTask(task)
setInitialRunId(null)
}}
>
日志
</TextButton>
<TextButton onClick={() => openEdit(task)}>编辑</TextButton>
<TextButton
disabled={isToggling}
onClick={() => toggleMutation.mutate(task)}
>
{task.enabled ? '停用' : '启用'}
</TextButton>
<TextButton tone="danger" onClick={() => setDeleteTarget(task)}>
删除
</TextButton>
</Space>
),
}
})
return (
<div className="page-stack">
<PageHeader
eyebrow="TASKPILOT / WINDOWS SCHEDULER"
title="任务调度"
subtitle="Rust 本地轻量化任务引擎 · Windows 原生调度中心"
actions={<Button onClick={openCreate}>+ 新建任务</Button>}
/>
<div className="dashboard">
<div className="dashboard-head">
<Space direction="column" gap="xs">
<Title level={2} as="h1">
任务调度
</Title>
<Text size="sm" tone="sub">
Rust 本地轻量化任务引擎 · Windows 原生调度中心
</Text>
</Space>
<Button onClick={openCreate}>+ 新建任务</Button>
</div>
{actionError ? (
<Notice
tone="error"
title="操作失败"
className="page-alert"
action={{ label: '关闭', onClick: () => setActionError(null) }}
>
{actionError}
</Notice>
) : null}
<section aria-label="任务统计" className="metrics-section">
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
<MetricCard
label="已托管任务"
value={statsQuery.data?.total}
loading={statsQuery.isPending}
/>
<MetricCard
label="运行中任务"
value={statsQuery.data?.running}
loading={statsQuery.isPending}
/>
<MetricCard
label="可用脚本"
value={statsQuery.data?.script}
loading={statsQuery.isPending}
/>
<MetricCard
label="Rust 引擎"
value={
healthQuery.isPending
? '...'
{/* 左栏:用户 / 概览 / 调度 */}
<aside className="dashboard-column dashboard-left" aria-label="概览">
<Card padding="md">
<div className="user-profile">
<Avatar
className="user-profile-avatar"
fallback="本"
size="xl"
tone="primary"
verified
/>
<div className="user-profile-name-row">
<Text size="lg" strong>
本地用户
</Text>
<span className="user-profile-status" aria-hidden="true" />
</div>
<Tag shape="pill" className="user-profile-role">
管理员
</Tag>
<p className="user-profile-bio">
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
</p>
<Text size="xs" tone="muted" className="user-profile-meta">
<span
className={
healthQuery.isError
? 'user-profile-engine-dot is-offline'
: 'user-profile-engine-dot'
}
aria-hidden="true"
/>
引擎
{healthQuery.isPending
? '检测中'
: healthQuery.isError
? '离线'
: '在线'
}
loading={false}
danger={healthQuery.isError}
/>
</Grid>
</section>
<Card padding="none" className="task-card">
<div className="toolbar">
<SearchField
className="toolbar-search"
value={queryDraft}
onChange={setQueryDraft}
placeholder="搜索任务名称或 ID"
size="md"
maxWidth={420}
/>
<div className="toolbar-filters">
<Select
className="filter-select"
options={ENABLED_OPTIONS}
value={enabledValue ?? ''}
onChange={(value) =>
updateFilters({
enabled: value || undefined,
page: '1',
})
}
/>
<Select
className="filter-select"
options={ACTION_TYPE_OPTIONS}
value={actionTypeValue ?? ''}
onChange={(value) =>
updateFilters({
action_type: value || undefined,
page: '1',
})
}
/>
{hasFilters ? (
<TextButton onClick={() => setSearchParams({})}>
清除筛选
</TextButton>
) : null}
<TextButton
onClick={() => void refreshAll()}
disabled={refreshing}
>
刷新
</TextButton>
: '在线'}
</Text>
</div>
</div>
</Card>
<Card padding="md">
<Text strong size="sm">
概览
</Text>
<div className="dash-metrics">
<DashMetric
label="已托管任务"
value={statsQuery.data?.total}
loading={statsQuery.isPending}
/>
<DashMetric
label="运行中任务"
value={statsQuery.data?.running}
loading={statsQuery.isPending}
/>
<DashMetric
label="可用脚本"
value={statsQuery.data?.script}
loading={statsQuery.isPending}
/>
<DashMetric
label="HTTP 请求"
value={statsQuery.data?.http}
loading={statsQuery.isPending}
/>
<DashMetric
label="二进制程序"
value={statsQuery.data?.process}
loading={statsQuery.isPending}
/>
</div>
</Card>
<Card padding="md">
<Text strong size="sm">
最近触发
</Text>
{nearest ? (
<div className="dash-inline">
<Text className="dash-inline-name" size="sm" truncate>
{nearestTask?.name ?? nearest.task_id}
</Text>
<Text className="dash-inline-time" size="sm">
{formatShortDateTime(nearest.next_run_at)}
</Text>
</div>
) : (
<Text className="dash-inline-empty" size="sm" tone="muted">
暂无
</Text>
)}
</Card>
</aside>
{/* 中栏:筛选 + 按类型分组的任务 */}
<section className="dashboard-column dashboard-main" aria-label="任务列表">
<Card padding="md">
<Space gap="sm" wrap align="center">
<SearchField
className="toolbar-search"
value={queryDraft}
onChange={setQueryDraft}
placeholder="搜索任务名称或 ID"
size="md"
maxWidth={420}
/>
<Space gap="xs" wrap align="center">
<Select
className="filter-select"
options={ENABLED_OPTIONS}
value={enabledValue ?? ''}
onChange={(value) =>
updateFilters({
enabled: value || undefined,
})
}
/>
<Select
className="filter-select"
options={ACTION_TYPE_OPTIONS}
value={actionTypeValue ?? ''}
onChange={(value) =>
updateFilters({
action_type: value || undefined,
})
}
/>
</Space>
</Space>
</Card>
{actionError ? (
<Notice
tone="error"
title="操作失败"
className="page-alert"
action={{ label: '关闭', onClick: () => setActionError(null) }}
>
{actionError}
</Notice>
) : null}
{tasksQuery.isError ? (
<Result
status="error"
title="任务列表加载失败"
description={tasksQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void tasksQuery.refetch()}
>
重试
</Button>
}
/>
) : tasksQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取任务" />
<Text tone="sub">正在读取任务...</Text>
</div>
) : tasks.length === 0 ? (
<Empty
className="empty-state"
text={
<span className="empty-copy">
<Text as="span" size="lg" strong>
{hasFilters ? '没有匹配任务' : '还没有任务'}
</Text>
<Text as="span" size="md" tone="sub">
{hasFilters
? '请调整搜索条件后重试。'
: '创建第一个定时任务,开始使用真实调度能力。'}
</Text>
</span>
}
action={
hasFilters ? undefined : (
<Button onClick={openCreate}>新建任务</Button>
)
}
/>
) : (
<>
<Table
className="task-table"
columns={TABLE_COLUMNS}
rows={rows}
size="md"
<Card padding="none">
<Result
status="error"
title="任务列表加载失败"
description={tasksQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void tasksQuery.refetch()}
>
重试
</Button>
}
/>
<footer className="pagination-bar">
<Text size="sm" tone="sub">
共 {total} 个任务,第 {page} / {totalPages} 页
</Text>
<Pagination
page={page}
total={total}
pageSize={PAGE_SIZE}
onChange={(next) => updateFilters({ page: String(next) })}
/>
</footer>
</>
</Card>
) : tasksQuery.isPending ? (
<Card padding="none">
<div className="state-block">
<Spinner label="正在读取任务" />
<Text tone="sub">正在读取任务...</Text>
</div>
</Card>
) : tasks.length === 0 ? (
<Card padding="none">
<Empty
className="empty-state"
text={
<span className="empty-copy">
<Text as="span" size="lg" strong>
{hasFilters ? '没有匹配任务' : '还没有任务'}
</Text>
<Text as="span" size="md" tone="sub">
{hasFilters
? '请调整搜索条件后重试。'
: '创建第一个定时任务,开始使用真实调度能力。'}
</Text>
</span>
}
action={
hasFilters ? undefined : (
<Button onClick={openCreate}>新建任务</Button>
)
}
/>
</Card>
) : (
sortedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
nextRunAt={nextRuns.get(task.id)}
running={
runMutation.isPending && runMutation.variables?.id === task.id
}
toggling={
toggleMutation.isPending &&
toggleMutation.variables?.id === task.id
}
onRun={() => runMutation.mutate(task)}
onLogs={() => {
setLogTask(task)
setInitialRunId(null)
}}
onEdit={() => openEdit(task)}
onToggle={() => toggleMutation.mutate(task)}
onDelete={() => setDeleteTarget(task)}
onDetails={() => setDetailTask(task)}
/>
))
)}
</Card>
</section>
{/* 右栏:uv / 镜像 / 虚拟环境摘要(配置走弹窗) */}
<aside className="dashboard-column dashboard-right" aria-label="Python 环境">
<Card padding="md">
<Space justify="between" align="center">
<Text strong size="sm">
uv
</Text>
<TextButton label="配置 uv" onClick={() => setUvSettingsOpen(true)}>
配置
</TextButton>
</Space>
<div className="dash-inline-kv">
<span className="dash-inline-item">
<Text size="xs" tone="muted">
版本
</Text>
<Text className="mono" size="sm">
{shortUvVersion(uv?.version) ?? '未检测到'}
</Text>
</span>
<span className="dash-inline-item">
<Text size="xs" tone="muted">
Python 解释器
</Text>
<Text size="sm">
{uv?.version
? pythonsQuery.isPending
? '读取中'
: pythonCount === 0
? '还没有下载'
: `${pythonCount} 个版本`
: '需要先安装 uv'}
</Text>
</span>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
缓存目录
</Text>
<Text className="mono" size="xs" tone="sub" title="UV_CACHE_DIR">
{uv?.cache_dir ?? 'uv-cache'}
</Text>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
Python 目录
</Text>
<Text className="mono" size="xs" tone="sub">
{uv?.python_dir ?? 'python'}
</Text>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
下载镜像
</Text>
<Text
className="mono"
size="xs"
tone="sub"
title={uv?.mirror_url ?? undefined}
>
{uv?.mirror_url ?? '默认'}
</Text>
</div>
</Card>
<Card padding="md">
<Space justify="between" align="center">
<Text strong size="sm">
镜像源
</Text>
<TextButton
label="配置镜像源"
onClick={() => setMirrorSettingsOpen(true)}
>
配置
</TextButton>
</Space>
<div className="mirror-card-summary">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Text strong size="sm" truncate>
{defaultMirror?.name ?? '未设置默认镜像'}
</Text>
<Text
className="mono mirror-card-url"
size="xs"
tone="sub"
title={defaultMirror?.index_url ?? undefined}
>
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
</Text>
<Text size="xs" tone="muted" className="mirror-card-inject">
运行时注入
</Text>
<Space gap="xs" wrap>
<Tag shape="rounded">PIP_INDEX_URL</Tag>
<Tag shape="rounded">UV_INDEX_URL</Tag>
{defaultMirror?.trusted_host ? (
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
) : null}
</Space>
</div>
</Card>
<Card padding="md">
<Space justify="between" align="center">
<Text strong size="sm">
虚拟环境
</Text>
<TextButton onClick={() => setEnvManagerOpen(true)}>
管理
</TextButton>
</Space>
{managedEnvs.length === 0 ? (
<Text size="sm" tone="muted">
还没有预创建的环境
</Text>
) : (
<div className="dash-list">
{managedEnvs.slice(0, 5).map((env) => (
<div className="dash-list-row" key={env.name}>
<Text size="sm" strong>
{env.name}
</Text>
<Text size="xs" tone="muted" className="mono">
{env.python_version ?? '—'}
</Text>
</div>
))}
</div>
)}
</Card>
</aside>
{/* 配置弹窗:uv(含解释器下载)与镜像源分开,各自一个弹窗 */}
{uvSettingsOpen ? (
<UvSettingsModal
open
onClose={() => setUvSettingsOpen(false)}
/>
) : null}
{mirrorSettingsOpen ? (
<Modal
open
onClose={() => setMirrorSettingsOpen(false)}
title="镜像源配置"
size="lg"
className="mirror-settings-modal"
footer={
<Button
variant="secondary"
onClick={() => setMirrorSettingsOpen(false)}
>
关闭
</Button>
}
>
<PythonMirrorsSection />
</Modal>
) : null}
{envManagerOpen ? (
<Modal
open
onClose={() => setEnvManagerOpen(false)}
title="虚拟环境管理"
size="lg"
footer={
<Button
variant="secondary"
onClick={() => setEnvManagerOpen(false)}
>
关闭
</Button>
}
>
<PythonEnvsSection />
</Modal>
) : null}
<TaskEditorModal
open={editorOpen}
@@ -499,6 +702,12 @@ export function TaskDashboard() {
}}
/>
<TaskDetailModal
task={detailTask}
nextRunAt={detailTask ? nextRuns.get(detailTask.id) : undefined}
onClose={() => setDetailTask(null)}
/>
<RunLogDrawer
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
open={Boolean(logTask)}
@@ -513,7 +722,7 @@ export function TaskDashboard() {
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除任务 ${deleteTarget?.id ?? ''}`}
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不可撤销。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
@@ -530,31 +739,124 @@ export function TaskDashboard() {
)
}
interface MetricCardProps {
interface DashMetricProps {
label: string
value: number | string | undefined
loading: boolean
danger?: boolean
}
function MetricCard({ label, value, loading, danger = false }: MetricCardProps) {
function DashMetric({ label, value, loading }: DashMetricProps) {
return (
<Card padding="md" className="metric-card" hoverable>
<Text tone="sub" size="sm">
<div className="dash-metric">
<Text size="xs" tone="muted">
{label}
</Text>
<Title
level={1}
as="div"
weight="medium"
className={danger ? 'metric-value metric-danger' : 'metric-value'}
>
<span className="dash-metric-value">
{loading ? '...' : (value ?? '--')}
</Title>
</span>
</div>
)
}
interface TaskItemProps {
task: Task
nextRunAt: string | undefined
running: boolean
toggling: boolean
onRun: () => void
onLogs: () => void
onEdit: () => void
onToggle: () => void
onDelete: () => void
onDetails: () => void
}
function TaskCard({
task,
nextRunAt,
running,
toggling,
onRun,
onLogs,
onEdit,
onToggle,
onDelete,
onDetails,
}: TaskItemProps) {
return (
<Card padding="sm">
<div className="task-card-head">
<EnabledBadge
enabled={task.enabled}
className="task-card-status-badge"
/>
<TextButton className="task-card-detail" onClick={onDetails}>
详情
</TextButton>
</div>
<div className="task-card">
<div className="task-card-main">
<Space gap="xs" align="center" wrap>
<Text className="task-card-title" size="lg" strong truncate>
{task.name}
</Text>
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
</Space>
<Text
className="task-card-desc"
size="xs"
tone="muted"
truncate
title={task.description.trim() || taskTarget(task)}
>
{task.description.trim() || taskTarget(task)}
</Text>
</div>
<div className="task-card-plan">
<code className="code-chip">{task.cron}</code>
<span className="task-card-next">
{task.enabled
? nextRunAt
? `下次 ${formatShortDateTime(nextRunAt)}`
: '调度中…'
: '未调度'}
</span>
</div>
<div className="task-card-side">
<Space className="task-card-actions" gap="xs" wrap justify="end">
<TextButton disabled={running} title="立即运行" onClick={onRun}>
{running ? '提交中' : '运行'}
</TextButton>
<TextButton onClick={onLogs}>日志</TextButton>
<TextButton onClick={onEdit}>编辑</TextButton>
<TextButton disabled={toggling} onClick={onToggle}>
{task.enabled ? '停用' : '启用'}
</TextButton>
<TextButton tone="danger" onClick={onDelete}>
删除
</TextButton>
</Space>
</div>
</div>
</Card>
)
}
/** 卡片第二行的一句话说明:任务标识 + 它实际执行什么。 */
function taskTarget(task: Task): string {
const action = task.action
if (action.type === 'http') {
return `${action.method} ${action.url}`
}
if (action.type === 'process') {
return action.executable
}
const directory = action.working_dir.replace(/[\\/]+$/, '')
return directory ? `${directory}\\${action.script}` : action.script
}
function isActionType(value: string | null): value is TaskActionType {
return value === 'script' || value === 'http' || value === 'process'
}
+12
View File
@@ -7,6 +7,12 @@ if (-not $releaseRoot.StartsWith($expectedPrefix, [System.StringComparison]::Ord
throw "Refusing to build outside release directory: $releaseRoot"
}
# 与 check.ps1 一致:临时目录放在仓库内,避免系统 %TEMP% 权限策略让 aws-lc-sys 的 cc 构建失败。
$scratch = Join-Path $projectRoot '.tmp'
[void](New-Item -ItemType Directory -Path $scratch -Force)
$env:TMP = $scratch
$env:TEMP = $scratch
function Assert-ExitCode {
param([string]$Step)
@@ -33,6 +39,8 @@ try {
}
[void](New-Item -ItemType Directory -Path $releaseRoot -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'web') -Force)
# uv 管理页默认从这个目录找自己下载的 uv.exe
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'uv') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'data') -Force)
[void](New-Item -ItemType Directory -Path (Join-Path $releaseRoot 'logs') -Force)
@@ -41,6 +49,10 @@ try {
Copy-Item -Path 'frontend\dist\*' -Destination (Join-Path $releaseRoot 'web') -Recurse
Copy-Item -LiteralPath 'docs\RELEASE.md' -Destination (Join-Path $releaseRoot 'README.md')
# 发布包不该继承工作区的低完整性标签:带着它启动的进程写不了 uv 缓存和用户文件。
& icacls $releaseRoot /setintegritylevel '(OI)(CI)M' /T | Out-Null
Assert-ExitCode 'Release integrity label'
Write-Host "Release package: $releaseRoot"
}
finally {