Compare commits

...
11 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
CNWei 2aa2a1ac18 fix(frontend): 环境探测不再显示加载态,只在最终结果更新
探测期间保留上一次最终结果,移除正在探测提示;首次无结果时保持空状态;占位结果不参与自动回填,最终结果返回后一次性更新。
2026-11-04 23:27:43 +08:00
CNWei 62f1661b2c fix(frontend): 切换目录时只保留一次虚拟环境状态
切换目录时先同步清掉旧目录自动填入的 venv;实时 venv 为空时立即忽略防抖旧值;旧查询结果不再回填新目录;补充 Playwright 回归用例。
2026-11-04 23:20:54 +08:00
CNWei 8501deba07 Revert "fix(frontend): 稳定虚拟环境探测状态,避免输入时闪烁"
This reverts commit 78ddc17fce.
2026-11-04 23:14:28 +08:00
CNWei 78ddc17fce fix(frontend): 稳定虚拟环境探测状态,避免输入时闪烁
探测状态保留上一次结果,新结果返回后再更新;空目录改为中性提示;防抖期间不因旧工作目录闪出警告;占位数据期间不自动回填虚拟环境。
2026-11-04 23:02:30 +08:00
CNWei 10f8e6bb66 style(frontend): 精简脚本语言提示并移除解释器输入框
- 脚本语言提示从大块灰色卡片改成下拉框右侧的一行说明
- Python 表单不再显示解释器输入框,固定使用 .venv 里的 Python
- 虚拟环境目录字段改为全宽并明确自动探测语义
- 非 Python 语言仍由语言自动设置 cmd.exe/node.exe/lua.exe,API 仍可传自定义 interpreter
2026-11-04 22:51:37 +08:00
CNWei 917674880b feat: 拆分任务类型并支持脚本语言联动
- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua)
- 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览
- 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器
- TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
2026-11-04 22:07:14 +08:00
CNWei 8e6b3b92b9 feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create
- 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像
- 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错
- 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器
- 补后端探测/创建测试、前端入口测试与 README
2026-11-03 22:57:17 +08:00
CNWei 29fc579275 feat: 左侧导航改版与 Python 镜像管理
- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航
- 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方
- Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先
- 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除
- 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
2026-11-03 22:12:11 +08:00
57 changed files with 10146 additions and 754 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 与镜像源不再同页)。
+137 -29
View File
@@ -6,6 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
- SQLite 持久化,重启后任务与历史仍在
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL`
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
@@ -49,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
@@ -63,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
```
@@ -110,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`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 |
| 界面 | 三类动态表单、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 契约、可选离线字体 |
@@ -131,22 +135,25 @@ 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
```jsonc
// 脚本
// 脚本(language 省略时按 interpreter 推断,旧任务兼容)
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
"interpreter": "python.exe", "script": "main.py",
"language": "python", "interpreter": "python.exe", "script": "main.py",
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
// Shell:cmd.exe /C run.bat;Node/Lua 同理
{ "type": "script", "working_dir": "D:\\Projects\\ops", "language": "shell",
"interpreter": "cmd.exe", "script": "run.bat", "args": [], "env": {} }
// HTTP
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
@@ -179,7 +186,6 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
- **重试**:字段已持久化但执行器尚未实现自动重试(见「已知限制」)。
---
@@ -205,7 +211,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
## Python 与虚拟环境
解析顺序(执行期与编辑器探测共用同一份逻辑):
编辑器和执行器共用同一份解析逻辑:
1. 手动指定的 `action.venv`
2. 从解释器路径推断(`...\.venv\Scripts\python.exe`)
@@ -213,6 +219,38 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
虚拟环境必须同时存在 `pyvenv.cfg` 与 `Scripts\python.exe`,否则显式配置在保存时被拒绝。
**没有虚拟环境时**:
- 先看用户自己的环境:任务里指定的 `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` 字段传入。
命中虚拟环境后注入:
| 变量 | 行为 |
@@ -229,14 +267,33 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
## Web 界面
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
页面级导航已去掉,配置都收到任务调度页的卡片里。
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **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` 生成,缺失时回退系统字体。
@@ -287,11 +344,42 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| `GET` | `/api/v1/session` | 获取当前进程的管理令牌 |
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境 |
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 |
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
### Python 镜像
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/python/mirrors` | 镜像列表(默认项优先) |
| `POST` | `/api/v1/python/mirrors` | 新增镜像 |
| `GET` | `/api/v1/python/mirrors/{id}` | 镜像详情 |
| `PUT` | `/api/v1/python/mirrors/{id}` | 更新名称 / 地址 / 信任主机 / 启用状态 |
| `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}` | 删除整个环境目录 |
### 任务与运行
| 方法 | 路径 | 说明 |
@@ -323,6 +411,7 @@ Content-Type: application/json
{
"id": "sync_data",
"name": "数据同步",
"description": "把上游数据同步到本地仓库,失败时记录日志",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
@@ -373,8 +462,10 @@ Content-Type: application/json
| `cron` | 可解析且存在未来触发点 |
| `timezone` | 有效 IANA 名称 |
| `timeout_secs` | 1–86400 |
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
| `action.working_dir` / `interpreter` / `script` | 非空 |
| `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`) |
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
@@ -395,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)。
---
@@ -424,8 +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/编码注入 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
| `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入、用 uv 建环境 |
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
@@ -434,9 +533,14 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| 模块 | 职责 |
| --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
| `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 归一) |
@@ -502,10 +606,10 @@ uv run --with fonttools --with brotli tools/build-fonts.py
| 层 | 覆盖 |
| --- | --- |
| Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
| 前端 Vitest(28) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(5) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动 |
| 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 全链路 |
---
@@ -528,11 +632,14 @@ uv run --with fonttools --with brotli tools/build-fonts.py
## 已知限制
- 自动重试尚未实现:`retry_policy` 只校验与存储。
- 并发策略只有 `skip`;没有全局并发上限与队列。
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
- Conda 环境不会自动激活,需要显式填写解释器路径。
- Python 任务缺少虚拟环境时,新建任务会先用 uv 创建(需要网络与可写的 uv 缓存目录);运行期不会再自动装依赖。已有环境的项目不会被自动升级。
- 托管虚拟环境只负责创建 / 删除 / 查看已装包,不会自动升级依赖;任务运行也不会改动它。
- 默认缓存与 Python 目录在程序目录下(`uv-cache` / `python`),删除发布目录等于丢掉缓存和解释器;想复用全局目录就把 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR` 改成绝对路径。
- 「下载 uv」依赖 `mirrors.ustc.edu.cn` 可达;网络不通时请自己下载 uv 放到程序同级 `uv` 目录或指定路径。
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
- 错过的调度不会补跑。
@@ -550,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);
+94 -8
View File
@@ -18,7 +18,21 @@ use crate::{
},
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
python_env::{
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
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::{
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
__path_list_python_mirrors, __path_set_default_python_mirror,
__path_update_python_mirror,
},
},
runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
@@ -31,15 +45,18 @@ use crate::{
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
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)]
@@ -128,21 +145,55 @@ 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)
}
#[utoipa::path(
post,
path = "/api/v1/python/venv/create",
tag = "system",
request_body = CreatePythonVenvRequest,
responses(
(status = 200, description = "Created or reused project virtual environment", body = VenvProbeResponse),
(status = 400, description = "Project cannot create a virtual environment", body = ErrorResponse)
)
)]
pub async fn create_python_venv(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreatePythonVenvRequest>,
) -> ApiResult<Json<VenvProbeResponse>> {
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
.await
.unwrap_or_else(|error| {
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
None
});
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(
get,
path = "/api/v1/system/directories",
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)
)
)]
@@ -219,6 +270,22 @@ pub async fn list_schedule(
session,
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,
@@ -244,18 +311,30 @@ pub async fn list_schedule(
CronPreviewResponse,
VenvProbeRequest,
VenvProbeResponse,
CreatePythonVenvRequest,
VenvProbeStatus,
VenvSource,
InterpreterKind,
PythonMirrorResponse,
CreatePythonMirrorRequest,
UpdatePythonMirrorRequest,
UvSettingsResponse,
UpdateUvSettingsRequest,
UvCandidate,
UvSource,
ManagedEnvSummary,
ManagedEnvPackage,
CreateManagedEnvRequest,
ManagedPython,
InstallPythonRequest,
DirectoryEntry,
DirectoryListingResponse,
TaskStatsResponse,
ScheduledTaskResponse,
ErrorResponse,
TaskActionType,
ScriptLanguage,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
@@ -300,11 +379,18 @@ pub fn router(state: AppState) -> Router {
"/api/v1/python/venv",
axum::routing::post(probe_python_venv),
)
.route(
"/api/v1/python/venv/create",
axum::routing::post(create_python_venv),
)
.route("/api/v1/system/directories", get(list_directories))
.route("/api/v1/stats", get(task_stats))
.route("/api/v1/schedule", get(list_schedule))
.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();
+30
View File
@@ -109,6 +109,36 @@ impl ApiError {
}
}
pub fn mirror_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "MIRROR_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
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,
code: "MIRROR_CONFLICT",
message: message.into(),
field: Some("index_url".to_owned()),
details: BTreeMap::new(),
}
}
pub fn route_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
+3
View File
@@ -5,11 +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;
+357 -45
View File
@@ -2,14 +2,20 @@ use std::{
collections::BTreeMap,
env,
ffi::OsString,
io,
fs, io,
path::{Path, PathBuf},
process::Stdio,
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::{
process::Command,
time::{Duration, timeout},
};
use utoipa::ToSchema;
use crate::python_mirrors::model::DefaultPythonMirror;
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
#[derive(Debug, Clone)]
@@ -27,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(
@@ -230,76 +258,148 @@ pub struct VenvProbeResponse {
pub display_path: Option<String>,
pub source: Option<VenvSource>,
pub message: String,
/// 本机 PATH 中是否能找到 uv
pub uv_available: bool,
/// 工作目录根部是否存在 pyproject.toml
pub has_pyproject: bool,
/// 工作目录根部是否存在 requirements*.txt
pub has_requirements: bool,
/// 探测到的 requirements 文件名(仅根目录)
pub requirements_files: Vec<String>,
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
pub can_create: bool,
}
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
/// 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreatePythonVenvRequest {
pub working_dir: String,
}
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
let working_dir = request.working_dir.trim();
let interpreter = request.interpreter.trim();
let interpreter = {
let trimmed = request.interpreter.trim();
if trimmed.is_empty() {
"python.exe"
} else {
trimmed
}
};
let configured_venv = request
.venv
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let interpreter_kind = classify_interpreter(interpreter);
let uv = crate::uv::probe_version(uv).is_some();
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
if working_dir.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写工作目录");
}
if interpreter.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
return respond(incomplete_probe(interpreter_kind, "请先填写工作目录"));
}
if !matches!(interpreter_kind, InterpreterKind::Python) {
return VenvProbeResponse {
return respond(VenvProbeResponse {
status: VenvProbeStatus::NotApplicable,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
};
..base_probe()
});
}
let working_directory = match absolute_path(Path::new(working_dir)) {
Ok(path) => path,
Err(error) => {
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
return respond(incomplete_probe(
interpreter_kind,
&format!("工作目录无效: {error}"),
));
}
};
if !working_directory.is_dir() {
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
return respond(incomplete_probe(
interpreter_kind,
"工作目录不存在,暂时无法探测虚拟环境",
));
}
match resolve_venv(working_dir, interpreter, configured_venv) {
Ok(ResolvedVenv {
venv: Some(venv),
source,
}) => VenvProbeResponse {
status: VenvProbeStatus::Linked,
interpreter_kind,
display_path: Some(python_display_path(&venv, working_dir)),
venv_path: Some(venv.to_string_lossy().into_owned()),
source,
message: venv_source_message(source),
},
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
status: VenvProbeStatus::Missing,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
},
Err(error) => VenvProbeResponse {
status: VenvProbeStatus::Invalid,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: error.to_string(),
respond(
match resolve_venv(working_dir, interpreter, configured_venv) {
Ok(ResolvedVenv {
venv: Some(venv),
source,
}) => VenvProbeResponse {
status: VenvProbeStatus::Linked,
interpreter_kind,
display_path: Some(python_display_path(&venv, working_dir)),
venv_path: Some(venv.to_string_lossy().into_owned()),
source,
message: venv_source_message(source),
..base_probe()
},
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
status: VenvProbeStatus::Missing,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
..base_probe()
},
Err(error) => VenvProbeResponse {
status: VenvProbeStatus::Invalid,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: error.to_string(),
..base_probe()
},
},
)
}
fn base_probe() -> VenvProbeResponse {
VenvProbeResponse {
status: VenvProbeStatus::Incomplete,
interpreter_kind: InterpreterKind::Other,
venv_path: None,
display_path: None,
source: None,
message: String::new(),
uv_available: false,
has_pyproject: false,
has_requirements: false,
requirements_files: Vec::new(),
can_create: false,
}
}
fn finalize_probe(
mut response: VenvProbeResponse,
working_dir: &str,
uv_available: bool,
) -> VenvProbeResponse {
let files = scan_project_files(working_dir);
response.uv_available = uv_available;
response.has_pyproject = files.has_pyproject;
response.has_requirements = !files.requirements.is_empty();
response.requirements_files = files.requirements;
response.can_create = response.status == VenvProbeStatus::Missing && uv_available;
if response.status == VenvProbeStatus::Missing {
response.message = if response.can_create {
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
} else {
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
};
}
response
}
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
VenvProbeResponse {
status: VenvProbeStatus::Incomplete,
@@ -308,9 +408,168 @@ fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvPro
display_path: None,
source: None,
message: message.to_owned(),
..base_probe()
}
}
/// 工作目录根部的 Python 依赖文件(不递归子目录)。
#[derive(Debug, Clone, Default)]
pub struct ProjectFiles {
pub has_pyproject: bool,
pub requirements: Vec<String>,
}
pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
let mut files = ProjectFiles::default();
let Ok(entries) = fs::read_dir(working_dir) else {
return files;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let lower = name.to_ascii_lowercase();
if lower == "pyproject.toml" {
files.has_pyproject = true;
} else if lower.starts_with("requirements") && lower.ends_with(".txt") {
files.requirements.push(name);
}
}
files.requirements.sort();
files
}
/// 用 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}"))?;
if !working_directory.is_dir() {
return Err("工作目录不存在,无法创建虚拟环境".to_owned());
}
let probe_request = VenvProbeRequest {
working_dir: working_dir.to_owned(),
interpreter: "python.exe".to_owned(),
venv: None,
};
let existing = probe_venv(&probe_request, uv);
if existing.status == VenvProbeStatus::Linked {
return Ok(existing);
}
let files = scan_project_files(working_dir);
if crate::uv::probe_version(uv).is_none() {
return Err(
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
);
}
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
if files.has_pyproject {
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
} else {
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
let python = working_directory
.join(".venv")
.join("Scripts")
.join("python.exe");
let python = python.to_string_lossy().into_owned();
for requirement in &files.requirements {
run_uv(
&working_directory,
&["pip", "install", "--python", &python, "-r", requirement],
mirror,
uv,
uv_env,
)
.await?;
}
}
let probe = probe_venv(&probe_request, uv);
if probe.status != VenvProbeStatus::Linked {
return Err(format!(
"uv 执行完成,但没有找到有效的 .venv:{}",
probe.message
));
}
Ok(probe)
}
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);
command
.args(args)
.current_dir(working_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(mirror) = mirror {
command
.env("PIP_INDEX_URL", &mirror.index_url)
.env("UV_INDEX_URL", &mirror.index_url)
.env("UV_DEFAULT_INDEX", &mirror.index_url);
if let Some(host) = mirror
.trusted_host
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
command.env("PIP_TRUSTED_HOST", host);
}
}
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
command.envs(uv_env);
#[cfg(windows)]
command.creation_flags(0x0800_0000);
let output = timeout(Duration::from_secs(600), command.output())
.await
.map_err(|_| format!("uv {} 超时(600 秒)", args.join(" ")))?
.map_err(|error| format!("无法启动 uv: {error}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
return Err(format!(
"uv {} 失败: {}",
args.join(" "),
truncate_output(detail)
));
}
Ok(stdout.into_owned())
}
fn truncate_output(value: &str) -> String {
const MAX_CHARS: usize = 1200;
if value.chars().count() <= MAX_CHARS {
return value.to_owned();
}
let mut truncated = value.chars().take(MAX_CHARS).collect::<String>();
truncated.push('…');
truncated
}
fn venv_source_message(source: Option<VenvSource>) -> String {
match source {
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
@@ -472,7 +731,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
std::path::absolute(path)
}
fn is_python_interpreter(interpreter: &str) -> bool {
pub fn is_python_interpreter(interpreter: &str) -> bool {
let name = Path::new(interpreter)
.file_name()
.and_then(|name| name.to_str())
@@ -562,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();
@@ -684,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]
@@ -735,6 +1021,32 @@ 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_eq!(response.can_create, response.uv_available);
}
#[test]
fn probe_reports_project_dependency_files_for_uv_creation() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("pyproject.toml"), "[project]\n").unwrap();
fs::write(temp.path().join("requirements-dev.txt"), "").unwrap();
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
assert_eq!(response.status, VenvProbeStatus::Missing);
assert!(response.has_pyproject);
assert!(response.has_requirements);
assert_eq!(response.requirements_files, vec!["requirements-dev.txt"]);
assert_eq!(response.can_create, response.uv_available);
}
#[test]
fn probe_offers_uv_creation_even_without_dependency_files() {
let temp = TempDir::new().unwrap();
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
// 纯标准库脚本也能建一个空环境,所以这里只看 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());
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod repository;
pub mod routes;
pub mod service;
+52
View File
@@ -0,0 +1,52 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// 一个 Python 包索引镜像(pip / uv)。
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PythonMirrorResponse {
pub id: String,
pub name: String,
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
pub index_url: String,
/// pip 的 `--trusted-host`;https 源通常为空
pub trusted_host: Option<String>,
pub enabled: bool,
/// 当前默认镜像;同一时间最多一个
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
pub enabled: bool,
}
const fn default_enabled() -> bool {
true
}
/// 执行器每次运行 Python 任务时读取的默认镜像。
#[derive(Debug, Clone)]
pub struct DefaultPythonMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
}
+215
View File
@@ -0,0 +1,215 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
python_mirrors::{
model::{DefaultPythonMirror, PythonMirrorResponse},
service::ValidatedMirror,
},
};
#[derive(Debug, FromRow)]
struct MirrorRow {
id: String,
name: String,
index_url: String,
trusted_host: Option<String>,
enabled: bool,
is_default: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<MirrorRow> for PythonMirrorResponse {
fn from(row: MirrorRow) -> Self {
Self {
id: row.id,
name: row.name,
index_url: row.index_url,
trusted_host: row.trusted_host,
enabled: row.enabled,
is_default: row.is_default,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
let rows = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let row = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
Ok(row.into())
}
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, None).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let id = Uuid::new_v4().simple().to_string();
let now = Utc::now();
sqlx::query(
"INSERT INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(map_mirror_error)?;
get(pool, &id).await
}
pub async fn update(
pool: &SqlitePool,
id: &str,
input: ValidatedMirror,
) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, Some(id)).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let now = Utc::now();
let result = sqlx::query(
"UPDATE python_mirrors
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
updated_at = ?
WHERE id = ?",
)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(input.enabled)
.bind(now)
.bind(id)
.execute(pool)
.await
.map_err(map_mirror_error)?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
Ok(())
}
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let now = Utc::now();
let mut transaction = pool.begin().await?;
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if exists.is_none() {
transaction.rollback().await?;
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
.bind(now)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
get(pool, id).await
}
/// 执行器读取:只返回「启用且是默认」的镜像。
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT name, index_url, trusted_host
FROM python_mirrors
WHERE enabled = 1 AND is_default = 1
LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
name,
index_url,
trusted_host,
}),
)
}
async fn index_url_exists(
pool: &SqlitePool,
index_url: &str,
excluding_id: Option<&str>,
) -> ApiResult<bool> {
let existing: Option<(String,)> = match excluding_id {
Some(id) => {
sqlx::query_as(
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
)
.bind(index_url)
.bind(id)
.fetch_optional(pool)
.await?
}
None => {
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
.bind(index_url)
.fetch_optional(pool)
.await?
}
};
Ok(existing.is_some())
}
fn map_mirror_error(error: sqlx::Error) -> ApiError {
if let sqlx::Error::Database(database_error) = &error
&& database_error.is_unique_violation()
{
return ApiError::mirror_conflict("该索引地址已经存在");
}
ApiError::from(error)
}
+151
View File
@@ -0,0 +1,151 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use crate::{
error::{ApiJson, ApiResult},
python_mirrors::{
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
repository, service,
},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/v1/python/mirrors",
get(list_python_mirrors).post(create_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}",
get(get_python_mirror)
.put(update_python_mirror)
.delete(delete_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}/default",
post(set_default_python_mirror),
)
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
responses(
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
)
)]
pub async fn list_python_mirrors(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
Ok(Json(repository::list(&state.pool).await?))
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
request_body = CreatePythonMirrorRequest,
responses(
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn create_python_mirror(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
let mirror = repository::create(&state.pool, input).await?;
Ok((StatusCode::CREATED, Json(mirror)))
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::get(&state.pool, &id).await?))
}
#[utoipa::path(
put,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
request_body = UpdatePythonMirrorRequest,
responses(
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn update_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
) -> ApiResult<Json<PythonMirrorResponse>> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
Ok(Json(repository::update(&state.pool, &id, input).await?))
}
#[utoipa::path(
delete,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 204, description = "Mirror deleted"),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
repository::delete(&state.pool, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors/{id}/default",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn set_default_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::set_default(&state.pool, &id).await?))
}
+79
View File
@@ -0,0 +1,79 @@
use url::Url;
use crate::error::{ApiError, ApiResult};
const MAX_NAME_CHARS: usize = 64;
const MAX_URL_CHARS: usize = 2048;
/// 校验并规范化后的镜像输入。
#[derive(Debug)]
pub struct ValidatedMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
pub enabled: bool,
}
pub fn validate(
name: &str,
index_url: &str,
trusted_host: Option<&str>,
enabled: bool,
) -> ApiResult<ValidatedMirror> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::validation("name", "请输入镜像名称"));
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(ApiError::validation(
"name",
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
));
}
let index_url = index_url.trim();
if index_url.is_empty() {
return Err(ApiError::validation("index_url", "请输入索引地址"));
}
if index_url.chars().count() > MAX_URL_CHARS {
return Err(ApiError::validation("index_url", "索引地址过长"));
}
let parsed = Url::parse(index_url)
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(ApiError::validation(
"index_url",
"索引地址只支持 http 或 https",
));
}
if parsed.host_str().is_none() {
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ApiError::validation(
"index_url",
"索引地址不能包含用户名或密码",
));
}
let trusted_host = trusted_host
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
return Err(ApiError::validation(
"trusted_host",
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
));
}
Ok(value.to_owned())
})
.transpose()?;
Ok(ValidatedMirror {
name: name.to_owned(),
index_url: index_url.to_owned(),
trusted_host,
enabled,
})
}
+67 -7
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
process::Stdio,
sync::{
@@ -31,7 +31,7 @@ use crate::{
model::{RunStatus, RunTrigger, TaskRunResponse},
repository,
},
tasks::model::{HttpMethod, TaskAction, TaskResponse},
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
};
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
@@ -429,12 +429,15 @@ impl TaskExecutor {
TaskAction::Script {
working_dir,
venv,
language,
interpreter,
script,
args,
env,
} => {
let prepared = match prepare_script_environment(
let script_language =
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
let mut prepared = match prepare_script_environment(
working_dir,
interpreter,
venv.as_deref(),
@@ -445,13 +448,58 @@ 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);
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
// 任务自己配的环境变量优先。
if script_language == ScriptLanguage::Python {
for (key, value) in uv.env() {
if !env_has(env, &key) {
command.env(key, value);
}
}
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);
}
}
}
}
if script_language == ScriptLanguage::Shell
&& is_cmd_interpreter(interpreter)
&& !script.trim_start().starts_with('/')
{
command.arg("/C");
}
command.arg(script).args(args).current_dir(working_dir);
let startup_note = prepared
.virtual_env
.as_ref()
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
self.run_command(task, run_id, cancellation, command, startup_note)
.await
}
@@ -1064,6 +1112,18 @@ async fn wait_after_termination(child: &mut Child) {
}
}
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
env.keys()
.any(|candidate| candidate.eq_ignore_ascii_case(key))
}
fn is_cmd_interpreter(interpreter: &str) -> bool {
Path::new(interpreter)
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
}
fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
-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}"))),
}
}
+3 -3
View File
@@ -198,7 +198,7 @@ fn lock_next_runs(
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, TaskAction};
use crate::tasks::model::{ScriptLanguage, TaskAction};
use super::*;
@@ -206,15 +206,15 @@ 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,
language: Some(ScriptLanguage::Shell),
interpreter: "cmd.exe".to_owned(),
script: "/C".to_owned(),
args: vec!["exit".to_owned(), "0".to_owned()],
+73 -27
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::{collections::BTreeMap, path::Path};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScriptLanguage {
Python,
Shell,
Node,
Lua,
}
impl ScriptLanguage {
pub const fn as_str(self) -> &'static str {
match self {
Self::Python => "python",
Self::Shell => "shell",
Self::Node => "node",
Self::Lua => "lua",
}
}
pub const fn default_interpreter(self) -> &'static str {
match self {
Self::Python => "python.exe",
Self::Shell => "cmd.exe",
Self::Node => "node.exe",
Self::Lua => "lua.exe",
}
}
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
pub fn from_interpreter(interpreter: &str) -> Self {
let stem = Path::new(interpreter)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(interpreter)
.to_ascii_lowercase();
if matches!(stem.as_str(), "node" | "nodejs") {
Self::Node
} else if stem.starts_with("lua") {
Self::Lua
} else if matches!(
stem.as_str(),
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
) {
Self::Shell
} else {
Self::Python
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskActionType {
@@ -34,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 {
@@ -57,6 +91,8 @@ pub enum TaskAction {
working_dir: String,
#[serde(default)]
venv: Option<String>,
#[serde(default)]
language: Option<ScriptLanguage>,
interpreter: String,
script: String,
#[serde(default)]
@@ -93,6 +129,18 @@ impl TaskAction {
Self::Process { .. } => TaskActionType::Process,
}
}
/// 脚本语言;旧任务未存 language 时从解释器推断。
pub fn script_language(&self) -> Option<ScriptLanguage> {
match self {
Self::Script {
language,
interpreter,
..
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
_ => None,
}
}
}
fn default_timezone() -> String {
@@ -112,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,
@@ -119,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,
}
@@ -130,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,
@@ -137,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,
}
@@ -148,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",
+40 -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,16 +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)?;
@@ -55,15 +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)?;
@@ -129,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(
@@ -148,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 时区名称")
@@ -180,11 +182,32 @@ fn validate_common(
Ok(())
}
fn normalize_action(action: &mut TaskAction) {
if let TaskAction::Script {
venv,
language,
interpreter,
..
} = action
{
let resolved =
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
*language = Some(resolved);
if interpreter.trim().is_empty() {
*interpreter = resolved.default_interpreter().to_owned();
}
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
*venv = None;
}
}
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
venv,
language: _,
interpreter,
script,
args,
@@ -332,6 +355,7 @@ mod tests {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
language: Some(ScriptLanguage::Python),
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
@@ -347,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");
@@ -360,7 +383,6 @@ mod tests {
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
@@ -372,6 +394,7 @@ mod tests {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
language: Some(ScriptLanguage::Python),
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
+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;
+243
View File
@@ -0,0 +1,243 @@
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();
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
(status, json)
}
#[tokio::test]
async fn seeds_python_mirrors_with_one_default() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let mirrors = body.as_array().expect("mirrors should be an array");
assert_eq!(mirrors.len(), 4);
let defaults: Vec<&Value> = mirrors
.iter()
.filter(|mirror| mirror["is_default"] == true)
.collect();
assert_eq!(defaults.len(), 1);
assert_eq!(defaults[0]["id"], "tsinghua");
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
}
#[tokio::test]
async fn mirrors_crud_and_default_switch() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, created) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "内网镜像",
"index_url": "https://pypi.example.local/simple",
"trusted_host": "pypi.example.local",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let id = created["id"]
.as_str()
.expect("created mirror should have an id")
.to_owned();
assert_eq!(created["is_default"], false);
assert_eq!(created["trusted_host"], "pypi.example.local");
let (status, mirror) = send_json(
&app,
Method::POST,
&format!("/api/v1/python/mirrors/{id}/default"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(mirror["is_default"], true);
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let defaults = list
.as_array()
.expect("mirrors should be an array")
.iter()
.filter(|mirror| mirror["is_default"] == true)
.count();
assert_eq!(defaults, 1);
let (status, updated) = send_json(
&app,
Method::PUT,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
Some(json!({
"name": "内网镜像(停用)",
"index_url": "https://pypi.example.local/simple",
"trusted_host": null,
"enabled": false
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["name"], "内网镜像(停用)");
assert_eq!(updated["enabled"], false);
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
assert_eq!(updated["is_default"], false);
let (status, _) = send_json(
&app,
Method::DELETE,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = send_json(
&app,
Method::GET,
&format!("/api/v1/python/mirrors/{id}"),
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
}
#[tokio::test]
async fn rejects_invalid_mirror_url_and_duplicate_index() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "坏地址",
"index_url": "ftp://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "index_url");
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "重复镜像",
"index_url": "https://pypi.org/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["code"], "MIRROR_CONFLICT");
}
#[tokio::test]
async fn mirror_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
None,
Some(json!({
"name": "未授权",
"index_url": "https://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+88 -2
View File
@@ -47,9 +47,22 @@ async fn fetch_token(app: &Router) -> String {
}
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv", token, payload).await
}
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv/create", token, payload).await
}
async fn post_json(
app: &Router,
uri: &str,
token: Option<&str>,
payload: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri("/api/v1/python/venv")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
@@ -68,7 +81,7 @@ async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (Statu
.into_body()
.collect()
.await
.expect("probe body should be readable")
.expect("response body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
@@ -179,3 +192,76 @@ async fn venv_probe_reports_missing_working_directory() {
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_probe_reports_project_dependency_files() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let project = temp_dir.path().join("deps-project");
fs::create_dir_all(&project).expect("project directory should be created");
fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"demo\"\n",
)
.expect("pyproject.toml should be written");
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8"),
"interpreter": "python.exe"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "missing");
assert_eq!(body["has_pyproject"], true);
assert_eq!(body["has_requirements"], true);
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
assert_eq!(body["can_create"], body["uv_available"]);
}
#[tokio::test]
async fn venv_create_rejects_missing_working_directory() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let absent = temp_dir.path().join("absent-project");
let (status, body) = post_create(
&app,
Some(&token),
json!({
"working_dir": absent.to_str().expect("absent path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VENV_CREATE_FAILED");
assert!(
body["message"]
.as_str()
.expect("message should be a string")
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_create_requires_management_token() {
let (app, temp_dir) = test_app().await;
let (status, body) = post_create(
&app,
None,
json!({
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+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");
}
+99 -6
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::{fs, time::Duration};
use axum::{
Json, Router,
@@ -201,6 +201,98 @@ 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;
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
fs::write(
app._temp_dir.path().join("hello.bat"),
"@echo off\r\necho shell-language-ok\r\n",
)
.expect("batch file should be written");
let mut task = script_task(
"shell_task",
&working_dir,
"cmd.exe",
"hello.bat",
vec![],
"0 0 2 * * *",
10,
);
task["action"]["language"] = json!("shell");
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/shell_task/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let run_id = run["id"].as_i64().expect("run id should be an integer");
let completed = wait_for_run(&app.router, run_id).await;
assert_eq!(completed["status"], "succeeded");
let log_response = send_json(
&app.router,
Method::GET,
&format!("/api/v1/runs/{run_id}/logs"),
None,
)
.await;
let log = response_json(log_response).await;
assert!(
log["content"]
.as_str()
.expect("log content should be a string")
.contains("shell-language-ok")
);
}
#[tokio::test]
async fn manual_script_run_captures_exit_code_and_logs() {
let app = test_app().await;
@@ -654,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;
}
+14
View File
@@ -113,6 +113,20 @@ fn script_task(id: &str, name: &str) -> Value {
})
}
#[tokio::test]
async fn empty_interpreter_defaults_to_python() {
let (app, _temp_dir) = test_app().await;
let mut payload = script_task("default_interpreter", "Default interpreter");
payload["action"]["interpreter"] = json!("");
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(payload)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let created = response_json(response).await;
assert_eq!(created["action"]["interpreter"], "python.exe");
assert_eq!(created["action"]["language"], "python");
}
#[tokio::test]
async fn task_crud_flow_works_end_to_end() {
let (app, _temp_dir) = test_app().await;
+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");
}
+1030 -41
View File
File diff suppressed because it is too large Load Diff
+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` 等子命令可能与平台预期的参数不一致。
+104 -1
View File
@@ -174,6 +174,63 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
await expect(page.getByText('还没有任务')).toBeVisible()
})
test('switches script language and adapts the form fields', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: '+ 新建任务' }).click()
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Python', exact: true }).click()
await page.getByRole('option', { name: 'Shell (cmd)' }).click()
await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0)
await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat')
await expect(dialog.locator('.cli-line-cmd')).toContainText(
'cmd.exe /C run.bat',
)
await dialog.getByRole('button', { name: 'Shell (cmd)' }).click()
await page.getByRole('option', { name: 'Node.js' }).click()
await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue(
'index.js',
)
await expect(dialog.locator('.cli-line-cmd')).toContainText(
'node.exe index.js',
)
await page.screenshot({
path: 'test-results/dashboard-script-language.png',
fullPage: true,
})
await dialog.getByRole('button', { name: '取消' }).click()
})
test('avoids a transient invalid venv when switching directories', async ({
page,
}) => {
const venvDir = createVenvFixture()
const emptyProject = join(venvDir, '..', 'empty-project')
mkdirSync(emptyProject, { recursive: true })
await page.goto('/')
await page.getByRole('button', { name: '+ 新建任务' }).click()
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
const workingDir = dialog.getByLabel(/项目工作目录/)
const bar = dialog.locator('.env-detector-bar')
await workingDir.fill(venvDir)
await expect(bar).toContainText('已关联虚拟环境')
await workingDir.fill(emptyProject)
const states = new Set<string>()
for (let index = 0; index < 30; index += 1) {
states.add(await bar.innerText())
await page.waitForTimeout(80)
}
expect([...states].some((text) => text.includes('缺少'))).toBe(false)
await expect(bar).toContainText('未检测到虚拟环境')
})
test('keeps the dashboard usable on a narrow viewport', async ({
page,
request,
@@ -246,7 +303,9 @@ test('renders without external network access', async ({ page }) => {
})
await page.goto('/')
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible()
await expect(
page.getByRole('heading', { name: '任务调度' }),
).toBeVisible()
const state = await page.evaluate(async () => {
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
@@ -331,3 +390,47 @@ test('keeps a long run log scrollable without rendering every line', async ({
fullPage: true,
})
})
test('manages python package mirrors', async ({ page, request }) => {
const headers = await authHeaders(request)
// 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。
const existing = await request.get('/api/v1/python/mirrors')
expect(existing.ok()).toBeTruthy()
const existingMirrors = (await existing.json()) as Array<{
id: string
index_url: string
}>
for (const mirror of existingMirrors) {
if (mirror.index_url.includes('pypi.e2e.example')) {
await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers })
}
}
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
await page.goto('/mirrors')
await expect(
page.getByRole('heading', { name: 'Python 镜像' }),
).toBeVisible()
await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible()
await page.getByRole('button', { name: '+ 新增镜像' }).click()
const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' })
await dialog.getByLabel('名称').fill('E2E 镜像')
await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple')
await dialog.getByRole('button', { name: '保存' }).click()
const row = page.getByRole('row', { name: /E2E 镜像/ })
await expect(row).toBeVisible()
await row.getByRole('button', { name: '设为默认' }).click()
await expect(row.getByText('默认', { exact: true })).toBeVisible()
await row.getByRole('button', { name: '删除' }).click()
await page
.getByRole('dialog', { name: /删除镜像/ })
.getByRole('button', { name: '确认删除' })
.click()
await expect(row).toHaveCount(0)
await request.post('/api/v1/python/mirrors/tsinghua/default', { headers })
})
+777 -27
View File
@@ -4,13 +4,68 @@
* 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。
*/
.app-shell {
/* ---------- 应用外壳:左侧导航 + 内容 ---------- */
.app-root {
min-height: 100vh;
}
.app-sider {
position: sticky;
top: 0;
display: flex;
width: min(1040px, calc(100% - 48px));
height: 100vh;
box-sizing: border-box;
flex-direction: column;
gap: 24px;
overflow-y: auto;
padding: 24px 16px;
background-color: var(--su-bg-card);
}
.app-sider-brand {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px;
}
.app-nav {
margin-top: 4px;
}
.app-sider-foot {
margin-top: auto;
border-top: 1px solid var(--su-border);
padding: 16px 8px 0;
}
.app-sider-status {
display: flex;
flex-direction: column;
gap: 8px;
}
.page-container {
width: 100%;
max-width: 1320px;
min-width: 0;
margin: 0 auto;
padding: 48px 0;
}
.page-stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 24px;
padding: 32px 32px 48px;
}
/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */
.page-stack > *,
.task-card,
.task-table {
min-width: 0;
}
/* ---------- 页头 ---------- */
@@ -84,7 +139,8 @@
}
.toolbar-search {
flex: 1 1 260px;
flex: 1 1 200px;
min-width: 180px;
max-width: 420px;
}
@@ -96,7 +152,7 @@
}
.filter-select {
min-width: 140px;
min-width: 110px;
}
.task-name-cell {
@@ -172,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 {
@@ -237,9 +283,21 @@
gap: 16px;
}
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
.venv-grid {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
.script-language-row {
display: flex;
align-items: flex-end;
gap: 16px;
}
.script-language-field {
min-width: 0;
flex: 0 0 260px;
}
.script-language-hint {
min-width: 0;
flex: 1;
padding-bottom: 10px;
}
.form-select {
@@ -389,6 +447,17 @@
color: var(--su-text-muted);
}
.env-status-badge.danger {
color: var(--su-color-danger);
}
.env-detector-message {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.env-status-badge code {
overflow: hidden;
border-radius: var(--su-radius-xs);
@@ -573,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);
@@ -876,8 +958,97 @@
background-color: var(--su-gray-6);
}
/* ---------- Python 镜像 ---------- */
.mirror-card {
overflow: hidden;
}
.mirror-table {
border: 0;
border-radius: 0;
}
.mirror-url {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mirror-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.mirror-enabled-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
/* ---------- 响应式 ---------- */
@media (max-width: 1100px) {
/* Layout 在窄屏把侧栏堆到上方;它的内部 main/inner 还保留内容最小宽度,
表格这类宽内容会把整页撑开,这里强制回到容器宽度。 */
.app-root > div,
.app-root > div > div,
.app-root main,
.app-root main > div {
width: 100%;
min-width: 0;
}
.app-sider {
position: static;
height: auto;
flex-direction: row;
align-items: center;
gap: 16px;
overflow: visible;
padding: 12px 16px;
}
.app-sider-brand {
flex-direction: row;
align-items: center;
gap: 10px;
padding: 0;
}
.app-sider-tagline {
display: none;
}
.app-nav {
flex: 1;
margin-top: 0;
}
/* Menu 内部是纵向 ul,窄屏改横向 */
.app-nav > ul {
flex-direction: row;
gap: 4px;
}
.app-nav > ul > li {
width: auto;
}
.app-nav > ul button {
width: auto;
}
.app-sider-foot {
margin-top: 0;
border-top: 0;
padding: 0;
}
}
@media (max-width: 1040px) {
.run-log-body {
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr);
@@ -885,10 +1056,17 @@
}
@media (max-width: 760px) {
.app-shell {
width: min(100% - 24px, 1040px);
gap: 20px;
padding: 24px 0 40px;
.page-container {
padding: 24px 20px 40px;
}
.app-sider {
gap: 10px;
padding: 10px 12px;
}
.app-sider-foot {
display: none;
}
.page-header {
@@ -920,8 +1098,17 @@
grid-template-columns: 1fr;
}
.venv-grid {
grid-template-columns: 1fr;
.script-language-row {
align-items: stretch;
flex-direction: column;
}
.script-language-field {
flex: none;
}
.script-language-hint {
padding-bottom: 0;
}
.cron-run-grid {
@@ -992,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);
}
+533 -13
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 =
@@ -83,6 +144,99 @@ function createFetchMock(options: FetchMockOptions = {}) {
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
])
}
if (url.endsWith('/api/v1/python/mirrors')) {
return jsonResponse([
{
id: 'tsinghua',
name: '清华 TUNA',
index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple',
trusted_host: null,
enabled: true,
is_default: true,
created_at: '2026-10-18T10:00:00Z',
updated_at: '2026-10-18T10:00:00Z',
},
])
}
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',
@@ -92,11 +246,27 @@ 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
}
const linked = probe.working_dir.toLowerCase().includes('sync')
const creatable = probe.working_dir.toLowerCase().includes('uvproject')
return jsonResponse({
status: linked ? 'linked' : 'missing',
interpreter_kind: 'python',
@@ -105,7 +275,14 @@ function createFetchMock(options: FetchMockOptions = {}) {
source: linked ? 'working_directory' : null,
message: linked
? '已自动关联工作目录中的虚拟环境'
: '未检测到虚拟环境,将直接使用系统解释器',
: creatable
? '未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv'
: '未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv',
uv_available: creatable,
has_pyproject: creatable,
has_requirements: false,
requirements_files: [],
can_create: creatable,
})
}
if (url.includes('/api/v1/system/directories')) {
@@ -161,17 +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('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: '配置 uv' }),
)
expect(
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('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 () => {
@@ -233,7 +735,7 @@ describe('TaskDashboard', () => {
).toBeInTheDocument()
})
it('falls back to the system interpreter when no venv is detected', async () => {
it('shows an error when no venv or dependency files exist', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
@@ -245,11 +747,29 @@ describe('TaskDashboard', () => {
)
expect(
await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }),
await screen.findByText(/未检测到虚拟环境;uv 不可用/, {}, {
timeout: 3000,
}),
).toBeInTheDocument()
})
it('offers uv environment creation when pyproject.toml exists', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.type(
screen.getByLabelText(/项目工作目录/),
'D:\\Projects\\uvproject',
)
expect(
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
await screen.findByRole('button', { name: '用 uv 创建环境' }, {
timeout: 3000,
}),
).toBeInTheDocument()
expect(screen.getByText('pyproject.toml')).toBeInTheDocument()
})
it('browses folders in the page and fills the working directory', async () => {
+18 -7
View File
@@ -1,5 +1,6 @@
import { Button, Result } from '@stash/ui'
import { Button, Layout, Result } from '@stash/ui'
import { Route, Routes, useNavigate } from 'react-router-dom'
import { AppHeader } from './components/AppHeader'
import { TaskDashboard } from './pages/TaskDashboard'
import './App.css'
@@ -7,23 +8,33 @@ function NotFoundPage() {
const navigate = useNavigate()
return (
<main className="app-shell">
<div className="page-stack">
<Result
status="info"
title="页面不存在"
description="该地址尚未配置到 TaskPilot 路由。"
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
/>
</main>
</div>
)
}
function App() {
return (
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Layout
className="app-root"
header={<AppHeader />}
headerVariant="solid"
maxWidth="full"
contentPadding="none"
>
<div className="page-container">
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Layout>
)
}
+139 -1
View File
@@ -18,6 +18,8 @@ export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
export type VenvProbeRequest = components['schemas']['VenvProbeRequest']
export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
export type CreatePythonVenvRequest =
components['schemas']['CreatePythonVenvRequest']
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
export type VenvSource = components['schemas']['VenvSource']
export type InterpreterKind = components['schemas']['InterpreterKind']
@@ -26,6 +28,22 @@ export type DirectoryListingResponse =
components['schemas']['DirectoryListingResponse']
export type TaskStats = components['schemas']['TaskStatsResponse']
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
export type PythonMirror = components['schemas']['PythonMirrorResponse']
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
@@ -280,16 +298,31 @@ export function probePythonVenv(
})
}
/** 用 uv 为项目创建/同步 .venv;优先复用已有环境。 */
export function createPythonVenv(
request: CreatePythonVenvRequest,
): Promise<VenvProbeResponse> {
return requestJson<VenvProbeResponse>('/api/v1/python/venv/create', {
method: 'POST',
body: JSON.stringify(request),
})
}
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
* `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 },
)
}
@@ -303,3 +336,108 @@ export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
}
/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */
export function listPythonMirrors(
signal?: AbortSignal,
): Promise<PythonMirror[]> {
return requestJson<PythonMirror[]>('/api/v1/python/mirrors', { signal })
}
export function createPythonMirror(
request: CreatePythonMirrorRequest,
): Promise<PythonMirror> {
return requestJson<PythonMirror>('/api/v1/python/mirrors', {
method: 'POST',
body: JSON.stringify(request),
})
}
export function updatePythonMirror(
id: string,
request: UpdatePythonMirrorRequest,
): Promise<PythonMirror> {
return requestJson<PythonMirror>(`/api/v1/python/mirrors/${id}`, {
method: 'PUT',
body: JSON.stringify(request),
})
}
export function deletePythonMirror(id: string): Promise<void> {
return requestJson<void>(`/api/v1/python/mirrors/${id}`, {
method: 'DELETE',
})
}
export function setDefaultPythonMirror(id: string): Promise<PythonMirror> {
return requestJson<PythonMirror>(
`/api/v1/python/mirrors/${id}/default`,
{ 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),
})
}
+797 -15
View File
@@ -52,6 +52,118 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_python_mirrors"];
put?: never;
post: operations["create_python_mirror"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/mirrors/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_python_mirror"];
put: operations["update_python_mirror"];
post?: never;
delete: operations["delete_python_mirror"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/python/mirrors/{id}/default": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["set_default_python_mirror"];
delete?: never;
options?: never;
head?: never;
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;
@@ -68,6 +180,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/python/venv/create": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["create_python_venv"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}": {
parameters: {
query?: never;
@@ -292,20 +420,68 @@ 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;
name: string;
trusted_host?: string | null;
};
/** @description 用 uv 为项目创建 `.venv`(pyproject 用 uv sync,requirements 用 uv venv + uv pip install)。 */
CreatePythonVenvRequest: {
working_dir: string;
};
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;
@@ -320,6 +496,8 @@ export interface components {
timezone: string;
};
DirectoryEntry: {
/** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */
is_dir: boolean;
/** @description 用于展示的名称 */
name: string;
/** @description 绝对路径,选中后回填到表单 */
@@ -352,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: {
@@ -360,16 +542,42 @@ export interface components {
/** Format: int64 */
next_offset: number;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: 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 */
created_at: string;
enabled: boolean;
id: string;
/** @description pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL` */
index_url: string;
/** @description 当前默认镜像;同一时间最多一个 */
is_default: boolean;
name: string;
/** @description pip 的 `--trusted-host`;https 源通常为空 */
trusted_host?: string | null;
/** Format: date-time */
updated_at: string;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
RunTrigger: "schedule" | "manual";
/** @description 已注册调度的任务及其下次触发时间。 */
ScheduledTaskResponse: {
/**
@@ -379,6 +587,8 @@ export interface components {
next_run_at?: string | null;
task_id: string;
};
/** @enum {string} */
ScriptLanguage: "python" | "shell" | "node" | "lua";
SessionResponse: {
/** @description Random per-process token required for mutating browser requests. */
token: string;
@@ -389,6 +599,7 @@ export interface components {
[key: string]: string;
};
interpreter: string;
language?: components["schemas"]["ScriptLanguage"] | null;
script: string;
/** @enum {string} */
type: "script";
@@ -427,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;
@@ -501,17 +712,74 @@ export interface components {
*/
total: number;
};
UpdatePythonMirrorRequest: {
enabled: boolean;
index_url: string;
name: string;
trusted_host?: string | null;
};
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` */
@@ -522,12 +790,22 @@ export interface components {
working_dir: string;
};
VenvProbeResponse: {
/** @description 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境) */
can_create: boolean;
/** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */
display_path?: string | null;
/** @description 工作目录根部是否存在 pyproject.toml */
has_pyproject: boolean;
/** @description 工作目录根部是否存在 requirements*.txt */
has_requirements: boolean;
interpreter_kind: components["schemas"]["InterpreterKind"];
message: string;
/** @description 探测到的 requirements 文件名(仅根目录) */
requirements_files: string[];
source?: components["schemas"]["VenvSource"] | null;
status: components["schemas"]["VenvProbeStatus"];
/** @description 本机 PATH 中是否能找到 uv */
uv_available: boolean;
/** @description 虚拟环境根目录的绝对路径 */
venv_path?: string | null;
};
@@ -620,6 +898,393 @@ 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;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Python package mirrors */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PythonMirrorResponse"][];
};
};
};
};
create_python_mirror: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreatePythonMirrorRequest"];
};
};
responses: {
/** @description Mirror created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PythonMirrorResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Mirror URL already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_python_mirror: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Mirror identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Mirror detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PythonMirrorResponse"];
};
};
/** @description Mirror not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
update_python_mirror: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Mirror identifier */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdatePythonMirrorRequest"];
};
};
responses: {
/** @description Mirror updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PythonMirrorResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Mirror not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Mirror URL already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete_python_mirror: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Mirror identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Mirror deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Mirror not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
set_default_python_mirror: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Mirror identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Default mirror updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PythonMirrorResponse"];
};
};
/** @description Mirror not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
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;
@@ -653,6 +1318,39 @@ export interface operations {
};
};
};
create_python_venv: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreatePythonVenvRequest"];
};
};
responses: {
/** @description Created or reused project virtual environment */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VenvProbeResponse"];
};
};
/** @description Project cannot create a virtual environment */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_run: {
parameters: {
query?: never;
@@ -857,6 +1555,8 @@ export interface operations {
query?: {
/** @description 绝对目录路径;留空表示列出盘符 */
path?: string;
/** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */
include_files?: boolean;
};
header?: never;
path?: never;
@@ -864,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;
@@ -1192,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>
)
}
@@ -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}
@@ -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)
}
}}
/>
</>
)
}
@@ -0,0 +1,214 @@
import { zodResolver } from '@hookform/resolvers/zod'
import {
Button,
Checkbox,
FormField,
Input,
Modal,
Notice,
Text,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { z } from 'zod'
import type { PythonMirror } from '../api/client'
const mirrorSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入镜像名称')
.max(64, '名称不能超过 64 个字符'),
index_url: z
.string()
.trim()
.min(1, '请输入索引地址')
.refine((value) => {
try {
const url = new URL(value)
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
Boolean(url.hostname) &&
!url.username &&
!url.password
)
} catch {
return false
}
}, '请输入 http(s) 开头的有效索引地址'),
trusted_host: z
.string()
.trim()
.refine(
(value) =>
!value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)),
'只填主机名,可带端口,例如 mirrors.aliyun.com',
),
enabled: z.boolean(),
})
export type PythonMirrorFormValues = z.infer<typeof mirrorSchema>
function emptyValues(): PythonMirrorFormValues {
return { name: '', index_url: '', trusted_host: '', enabled: true }
}
function toValues(mirror: PythonMirror): PythonMirrorFormValues {
return {
name: mirror.name,
index_url: mirror.index_url,
trusted_host: mirror.trusted_host ?? '',
enabled: mirror.enabled,
}
}
interface PythonMirrorModalProps {
open: boolean
mirror: PythonMirror | null
saving: boolean
onClose: () => void
onSubmit: (values: PythonMirrorFormValues) => Promise<void>
}
export function PythonMirrorModal({
open,
mirror,
saving,
onClose,
onSubmit,
}: PythonMirrorModalProps) {
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PythonMirrorFormValues>({
resolver: zodResolver(mirrorSchema),
defaultValues: emptyValues(),
mode: 'onSubmit',
})
useEffect(() => {
if (!open) {
return
}
reset(mirror ? toValues(mirror) : emptyValues())
setSubmitError(null)
}, [mirror, open, reset])
const requestClose = () => {
if (!saving) {
onClose()
}
}
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
await onSubmit(values)
} catch (error) {
setSubmitError(error instanceof Error ? error.message : '保存镜像失败')
}
})
return (
<Modal
open={open}
onClose={requestClose}
title={mirror ? '编辑 Python 镜像' : '新增 Python 镜像'}
size="md"
closeOnOverlay={!saving}
footer={
<>
<Button variant="secondary" onClick={requestClose} disabled={saving}>
取消
</Button>
<Button
type="submit"
form="python-mirror-form"
loading={saving}
>
保存
</Button>
</>
}
>
<form
id="python-mirror-form"
className="mirror-form"
onSubmit={submit}
noValidate
>
{submitError ? (
<Notice tone="error" title="保存失败">
{submitError}
</Notice>
) : null}
<FormField label="名称" htmlFor="mirror-name" error={errors.name?.message}>
<Input
id="mirror-name"
{...register('name')}
placeholder="例如:清华 TUNA"
aria-invalid={Boolean(errors.name)}
/>
</FormField>
<FormField
label="索引地址"
htmlFor="mirror-url"
hint="pip 的 index-url,例如 https://pypi.tuna.tsinghua.edu.cn/simple"
error={errors.index_url?.message}
>
<Input
id="mirror-url"
{...register('index_url')}
className="mono-input"
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
spellCheck={false}
aria-invalid={Boolean(errors.index_url)}
/>
</FormField>
<FormField
label="信任主机(可选)"
htmlFor="mirror-trusted-host"
hint="只有 http 源需要;https 源留空。会注入 PIP_TRUSTED_HOST"
error={errors.trusted_host?.message}
>
<Input
id="mirror-trusted-host"
{...register('trusted_host')}
className="mono-input"
placeholder="mirrors.aliyun.com"
spellCheck={false}
aria-invalid={Boolean(errors.trusted_host)}
/>
</FormField>
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Checkbox
checked={field.value}
onChange={field.onChange}
label={
<span className="mirror-enabled-copy">
<Text strong size="sm">
启用此镜像
</Text>
<Text size="xs" tone="muted">
停用后不会参与默认镜像选择
</Text>
</span>
}
/>
)}
/>
</form>
</Modal>
)
}
@@ -0,0 +1,251 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Empty,
Notice,
Result,
Space,
Spinner,
Table,
Tag,
Text,
TextButton,
toast,
type TableColumn,
} from '@stash/ui'
import { useState } from 'react'
import {
createPythonMirror,
deletePythonMirror,
listPythonMirrors,
setDefaultPythonMirror,
updatePythonMirror,
type PythonMirror,
} from '../api/client'
import { ConfirmDialog } from './ConfirmDialog'
import {
PythonMirrorModal,
type PythonMirrorFormValues,
} from './PythonMirrorModal'
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称', width: 180 },
{ key: 'index_url', title: '索引地址', width: 330 },
{ key: 'trusted_host', title: '信任主机', width: 130 },
{ key: 'status', title: '状态', width: 100 },
{ key: 'actions', title: '操作', align: 'right', width: 220 },
]
function toRequest(values: PythonMirrorFormValues) {
return {
name: values.name.trim(),
index_url: values.index_url.trim(),
trusted_host: values.trusted_host.trim() || null,
enabled: values.enabled,
}
}
/**
* 「Python 环境」页的包索引镜像区块。
*
* 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入
* `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。
*/
export function PythonMirrorsSection() {
const queryClient = useQueryClient()
const [editorOpen, setEditorOpen] = useState(false)
const [editingMirror, setEditingMirror] = useState<PythonMirror | null>(null)
const [deleteTarget, setDeleteTarget] = useState<PythonMirror | null>(null)
const mirrorsQuery = useQuery({
queryKey: ['python-mirrors'],
queryFn: ({ signal }) => listPythonMirrors(signal),
})
const saveMutation = useMutation({
mutationFn: ({
mirror,
values,
}: {
mirror: PythonMirror | null
values: PythonMirrorFormValues
}) =>
mirror
? updatePythonMirror(mirror.id, toRequest(values))
: createPythonMirror(toRequest(values)),
onSuccess: (mirror, variables) => {
setEditorOpen(false)
setEditingMirror(null)
toast.success(
variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`,
)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
})
const defaultMutation = useMutation({
mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id),
onSuccess: (mirror) => {
toast.success(`默认镜像已切换为 ${mirror.name}`)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
onError: (error) => toast.error(error.message),
})
const deleteMutation = useMutation({
mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id),
onSuccess: (_data, mirror) => {
setDeleteTarget(null)
toast.success(`已删除 ${mirror.name}`)
void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] })
},
onError: (error) => toast.error(error.message),
})
const mirrors = mirrorsQuery.data ?? []
const openCreate = () => {
setEditingMirror(null)
setEditorOpen(true)
}
const openEdit = (mirror: PythonMirror) => {
setEditingMirror(mirror)
setEditorOpen(true)
}
const rows = mirrors.map((mirror) => ({
id: mirror.id,
name: (
<Space gap="xs" align="center" wrap>
<Text strong>{mirror.name}</Text>
{mirror.is_default ? (
<Tag tone="primary" shape="rounded">
默认
</Tag>
) : null}
</Space>
),
index_url: (
<code className="code-chip mirror-url" title={mirror.index_url}>
{mirror.index_url}
</code>
),
trusted_host: mirror.trusted_host ? (
<Text className="mono" size="sm">
{mirror.trusted_host}
</Text>
) : (
<Text size="sm" tone="muted">
—
</Text>
),
status: (
<Tag tone={mirror.enabled ? 'success' : 'neutral'} dot>
{mirror.enabled ? '已启用' : '已停用'}
</Tag>
),
actions: (
<Space justify="end" gap="xs" wrap>
<TextButton
disabled={mirror.is_default}
onClick={() => defaultMutation.mutate(mirror)}
>
设为默认
</TextButton>
<TextButton onClick={() => openEdit(mirror)}>编辑</TextButton>
<TextButton tone="danger" onClick={() => setDeleteTarget(mirror)}>
删除
</TextButton>
</Space>
),
}))
return (
<>
<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>
<Button onClick={openCreate}>+ 新增镜像</Button>
</Space>
</Card>
<Notice title="镜像只影响任务运行,不会自动安装依赖">
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
优先。修改后对下一次运行生效。
</Notice>
<Card padding="none" className="mirror-card">
{mirrorsQuery.isError ? (
<Result
status="error"
title="镜像列表加载失败"
description={mirrorsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void mirrorsQuery.refetch()}
>
重试
</Button>
}
/>
) : mirrorsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取镜像" />
<Text tone="sub">正在读取镜像...</Text>
</div>
) : mirrors.length === 0 ? (
<Empty
className="empty-state"
text="还没有 Python 镜像"
action={<Button onClick={openCreate}>新增镜像</Button>}
/>
) : (
<Table
className="mirror-table"
columns={TABLE_COLUMNS}
rows={rows}
/>
)}
</Card>
<PythonMirrorModal
open={editorOpen}
mirror={editingMirror}
saving={saveMutation.isPending}
onClose={() => {
if (!saveMutation.isPending) {
setEditorOpen(false)
}
}}
onSubmit={async (values) => {
await saveMutation.mutateAsync({ mirror: editingMirror, values })
}}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除镜像 ${deleteTarget?.name ?? ''}`}
description="删除后如果它正是默认镜像,Python 任务将不再注入镜像环境变量;正在运行的任务不受影响。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</>
)
}
-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>
)
}
+327 -85
View File
@@ -1,5 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {
Button,
Checkbox,
@@ -14,6 +19,7 @@ import {
Text,
Textarea,
TextButton,
toast,
} from '@stash/ui'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
@@ -25,6 +31,7 @@ import {
} from 'react-hook-form'
import {
ApiRequestError,
createPythonVenv,
previewCron,
probePythonVenv,
type Task,
@@ -34,9 +41,11 @@ import { DirectoryPickerDialog } from './DirectoryPickerDialog'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
SCRIPT_LANGUAGE_META,
taskFormSchema,
taskToFormValues,
type KeyValueFormValue,
type ScriptLanguage,
type TaskFormValues,
} from '../lib/taskForm'
import { useDebouncedValue } from '../lib/useDebouncedValue'
@@ -50,11 +59,17 @@ interface TaskEditorModalProps {
}
const ACTION_TYPE_OPTIONS = [
{ value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' },
{ value: 'script', label: '执行脚本' },
{ value: 'http', label: '发起 HTTP 请求' },
{ value: 'process', label: '管理常驻/独立二进制' },
{ value: 'process', label: '运行二进制程序' },
]
const SCRIPT_LANGUAGE_OPTIONS = (
Object.entries(SCRIPT_LANGUAGE_META) as Array<
[ScriptLanguage, (typeof SCRIPT_LANGUAGE_META)[ScriptLanguage]]
>
).map(([value, meta]) => ({ value, label: meta.label }))
const HTTP_METHOD_OPTIONS = [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
@@ -81,6 +96,9 @@ 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()
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
const autoVenvRef = useRef('')
@@ -106,6 +124,9 @@ export function TaskEditorModal({
const cronValue = watch('cron')
const timezoneValue = watch('timezone')
const scriptPreview = watch('script')
const scriptLanguage = scriptPreview.language
const scriptMeta = SCRIPT_LANGUAGE_META[scriptLanguage]
const isPythonScript = scriptLanguage === 'python'
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
// 避免下拉显示成空值。
const timezoneOptions = Array.from(
@@ -122,6 +143,10 @@ export function TaskEditorModal({
350,
)
const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350)
// 切换工作目录时会同步清掉自动填入的 venv;如果实时值已经为空,
// 探测必须立刻当作没有 venv,不能继续用防抖中的旧值,
// 否则会先报“缺少 pyvenv.cfg”,再跳到“未检测到虚拟环境”。
const probeVenv = scriptPreview.venv.trim() ? debouncedVenv : ''
const cronPreview = useQuery({
queryKey: [
'cron-preview',
@@ -144,31 +169,103 @@ export function TaskEditorModal({
Boolean(debouncedTimezone.trim()),
retry: false,
})
const applyScriptLanguage = (language: ScriptLanguage) => {
const meta = SCRIPT_LANGUAGE_META[language]
const currentScript = getValues('script.script').trim()
const knownDefaults = Object.values(SCRIPT_LANGUAGE_META).map(
(item) => item.defaultScript,
)
setValue('script.language', language, {
shouldDirty: true,
shouldValidate: true,
})
setValue('script.interpreter', meta.defaultInterpreter, {
shouldDirty: true,
shouldValidate: true,
})
if (!currentScript || knownDefaults.includes(currentScript)) {
setValue('script.script', meta.defaultScript, {
shouldDirty: true,
shouldValidate: true,
})
}
if (language !== 'python') {
setValue('script.venv', '', {
shouldDirty: true,
shouldValidate: true,
})
autoVenvRef.current = ''
}
}
const clearAutoVenv = () => {
if (
autoVenvRef.current &&
getValues('script.venv').trim() === autoVenvRef.current
) {
setValue('script.venv', '', { shouldDirty: false })
autoVenvRef.current = ''
}
}
const workingDirField = register('script.working_dir')
const venvProbe = useQuery({
queryKey: [
'python-venv',
debouncedWorkingDir,
debouncedInterpreter,
debouncedVenv,
probeVenv,
scriptLanguage,
],
queryFn: ({ signal }) =>
probePythonVenv(
{
working_dir: debouncedWorkingDir,
interpreter: debouncedInterpreter,
venv: debouncedVenv.trim() || null,
interpreter: debouncedInterpreter.trim() || 'python.exe',
venv: probeVenv.trim() || null,
},
signal,
),
enabled:
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
open &&
actionType === 'script' &&
isPythonScript &&
Boolean(debouncedWorkingDir.trim()),
// 探测期间保留上一次最终结果,不显示“正在检测”这种中间状态。
placeholderData: keepPreviousData,
retry: false,
staleTime: 15_000,
})
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
const previewVirtualEnv =
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
const previewInterpreter = linkedVenv?.display_path ?? null
const createVenvMutation = useMutation({
mutationFn: (workingDir: string) =>
createPythonVenv({ working_dir: workingDir }),
onSuccess: (probe, workingDir) => {
toast.success('虚拟环境已创建')
if (probe.venv_path) {
const value = venvFieldValue(workingDir, probe.venv_path)
autoVenvRef.current = value
setValue('script.venv', value, {
shouldDirty: true,
shouldValidate: true,
})
}
void queryClient.invalidateQueries({ queryKey: ['python-venv'] })
},
onError: (error) => toast.error(error.message),
})
const linkedVenv =
isPythonScript &&
!venvProbe.isPlaceholderData &&
venvProbe.data?.status === 'linked'
? venvProbe.data
: null
const previewVirtualEnv = isPythonScript
? scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
: ''
const previewInterpreter = isPythonScript
? (linkedVenv?.display_path ?? null)
: null
const scriptArgs = useFieldArray({ control, name: 'script.args' })
const scriptEnv = useFieldArray({ control, name: 'script.env' })
@@ -192,21 +289,28 @@ export function TaskEditorModal({
if (!open) {
return
}
if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) {
if (
autoVenvRef.current &&
getValues('script.venv').trim() === autoVenvRef.current
) {
setValue('script.venv', '', { shouldDirty: false })
autoVenvRef.current = ''
}
autoVenvRef.current = ''
}, [debouncedWorkingDir, getValues, open, setValue])
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
useEffect(() => {
if (!open) {
if (!open || !isPythonScript || venvProbe.isPlaceholderData) {
return
}
const probe = venvProbe.data
if (!probe || probe.status !== 'linked' || !probe.venv_path) {
return
}
// 工作目录还在防抖中时,旧查询结果不能回填到新目录。
if (debouncedWorkingDir !== scriptPreview.working_dir.trim()) {
return
}
const current = getValues('script.venv').trim()
// 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」
// 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。
@@ -225,7 +329,7 @@ export function TaskEditorModal({
shouldDirty: true,
shouldValidate: true,
})
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
}, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, scriptPreview.working_dir, setValue, venvProbe.data, venvProbe.isPlaceholderData])
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
@@ -236,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()
}
}
@@ -245,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) {
@@ -270,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>
</>
}
@@ -319,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 表达式"
@@ -455,6 +613,32 @@ export function TaskEditorModal({
</FormField>
</div>
{actionType === 'script' ? (
<div className="script-language-row">
<div className="script-language-field">
<FormField label="脚本语言">
<Controller
control={control}
name="script.language"
render={({ field }) => (
<Select
className="form-select"
options={SCRIPT_LANGUAGE_OPTIONS}
value={field.value}
onChange={(value) =>
applyScriptLanguage(value as ScriptLanguage)
}
/>
)}
/>
</FormField>
</div>
<Text className="script-language-hint" size="sm" tone="sub">
{scriptMeta.hint}
</Text>
</div>
) : null}
{actionType === 'script' ? (
<div className="dynamic-fields">
<FormField
@@ -465,7 +649,13 @@ export function TaskEditorModal({
<div className="field-with-action">
<Input
id="task-working-dir"
{...register('script.working_dir')}
{...workingDirField}
onChange={(event) => {
// 先清掉旧目录自动填入的 venv,再更新工作目录;
// 这样新目录的第一次探测就不会带着旧 .venv 去报“缺少 pyvenv.cfg”。
clearAutoVenv()
void workingDirField.onChange(event)
}}
className="mono-input"
placeholder="D:\Projects\sync"
aria-invalid={Boolean(errors.script?.working_dir)}
@@ -482,43 +672,36 @@ export function TaskEditorModal({
</div>
</FormField>
<div className="form-grid venv-grid">
<FormField
label="虚拟环境目录 (可选)"
htmlFor="task-venv"
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
error={errors.script?.venv?.message}
>
<Input
id="task-venv"
{...register('script.venv')}
className="mono-input"
placeholder="例如:.venv"
/>
</FormField>
<FormField
label="解释器"
htmlFor="task-interpreter"
error={errors.script?.interpreter?.message}
>
<Input
id="task-interpreter"
{...register('script.interpreter')}
className="mono-input"
placeholder="python.exe"
/>
</FormField>
</div>
{isPythonScript ? (
<>
<FormField
label="虚拟环境目录"
htmlFor="task-venv"
hint="自动探测项目里的 .venv;需要时也可以手动指定。Python 任务始终使用 .venv 里的 Python"
error={errors.script?.venv?.message}
>
<Input
id="task-venv"
{...register('script.venv')}
className="mono-input"
placeholder="留空自动探测,例如 .venv"
/>
</FormField>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
/>
<VenvDetectorBar
workingDir={scriptPreview.working_dir}
probe={venvProbe.data}
failed={venvProbe.isError}
creating={createVenvMutation.isPending}
onCreate={() =>
createVenvMutation.mutate(scriptPreview.working_dir.trim())
}
/>
</>
) : null}
<FormField
label="脚本文件或相对路径"
label={scriptMeta.scriptLabel}
htmlFor="task-script"
error={errors.script?.script?.message}
>
@@ -526,7 +709,7 @@ export function TaskEditorModal({
id="task-script"
{...register('script.script')}
className="mono-input"
placeholder="main.py 或 scripts\sync.py"
placeholder={scriptMeta.scriptPlaceholder}
/>
</FormField>
@@ -612,9 +795,11 @@ export function TaskEditorModal({
</div>
))}
<div className="cli-line-cmd">
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
{scriptPreview.script.trim() || 'main.py'}
{formatArgumentPreview(scriptPreview.args)}
{formatScriptCommandPreview(
scriptPreview,
previewInterpreter,
scriptLanguage,
)}
</div>
</div>
</div>
@@ -803,6 +988,7 @@ export function TaskEditorModal({
open={pickerOpen}
initialPath={scriptPreview.working_dir}
onSelect={(path) => {
clearAutoVenv()
setValue('script.working_dir', path, {
shouldDirty: true,
shouldValidate: true,
@@ -852,51 +1038,88 @@ function venvFieldValue(workingDir: string, venvPath: string): string {
function formatInterpreterPreview(
script: TaskFormValues['script'],
linkedPythonPath?: string | null,
linkedPythonPath: string | null | undefined,
language: ScriptLanguage,
): string {
if (script.venv.trim()) {
if (language === 'python' && script.venv.trim()) {
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
}
return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe')
if (linkedPythonPath) {
return linkedPythonPath
}
if (script.interpreter.trim()) {
return script.interpreter.trim()
}
return language === 'python'
? 'python.exe'
: SCRIPT_LANGUAGE_META[language].defaultInterpreter
}
function isCmdInterpreter(interpreter: string): boolean {
return (
interpreter
.trim()
.split(/[\\/]/)
.pop()
?.toLowerCase()
.replace(/\.exe$/, '') === 'cmd'
)
}
function formatScriptCommandPreview(
script: TaskFormValues['script'],
linkedPythonPath: string | null | undefined,
language: ScriptLanguage,
): string {
const interpreter = formatInterpreterPreview(
script,
linkedPythonPath,
language,
)
const file =
script.script.trim() || SCRIPT_LANGUAGE_META[language].defaultScript
const cmdPrefix =
language === 'shell' &&
isCmdInterpreter(script.interpreter) &&
!file.startsWith('/')
? '/C '
: ''
return `${interpreter} ${cmdPrefix}${file}${formatArgumentPreview(script.args)}`
}
interface VenvDetectorBarProps {
workingDir: string
probe: VenvProbeResponse | undefined
pending: boolean
failed: boolean
creating: boolean
onCreate: () => void
}
function VenvDetectorBar({
workingDir,
probe,
pending,
failed,
creating,
onCreate,
}: VenvDetectorBarProps) {
if (!workingDir.trim()) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">
● 请输入有效的工作目录以探测环境
</span>
<span className="env-detector-hint">填写工作目录后自动检测环境</span>
</div>
)
}
if (pending) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge pending">⏳ 正在探测解释器...</span>
</div>
)
}
if (failed || !probe) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
</div>
)
if (!probe) {
if (failed) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
</div>
)
}
// 还没有最终结果:保持空状态,不显示跳动的“正在检测”。
return <div className="env-detector-bar" role="status" />
}
if (probe.status === 'linked') {
@@ -926,12 +1149,31 @@ function VenvDetectorBar({
}
if (probe.status === 'missing') {
if (probe.can_create) {
return (
<div className="env-detector-bar" role="status">
<div className="env-detector-message">
<span className="env-status-badge warn">● {probe.message}</span>
<span className="env-detector-hint">
{probe.has_pyproject
? 'pyproject.toml'
: probe.requirements_files.join(' · ')}
</span>
</div>
<Button
size="sm"
variant="secondary"
loading={creating}
onClick={onCreate}
>
用 uv 创建环境
</Button>
</div>
)
}
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge ok">
● 使用系统解释器: <code>{probe.display_path}</code>
</span>
<span className="env-detector-hint">{probe.message}</span>
<span className="env-status-badge danger">● {probe.message}</span>
</div>
)
}
+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: '运行中',
+20
View File
@@ -28,6 +28,26 @@ describe('task form payload', () => {
})
})
it('submits the selected script language and interpreter', () => {
const values = createEmptyTaskFormValues()
values.id = 'shell_task'
values.name = 'Shell task'
values.script.language = 'shell'
values.script.working_dir = 'D:\\Projects\\shell-task'
values.script.interpreter = 'cmd.exe'
values.script.script = 'run.bat'
const payload = valuesToCreateRequest(values)
expect(payload.action).toMatchObject({
type: 'script',
language: 'shell',
working_dir: 'D:\\Projects\\shell-task',
interpreter: 'cmd.exe',
script: 'run.bat',
})
})
it('creates an HTTP action instead of forcing script type', () => {
const values = createEmptyTaskFormValues()
values.id = 'health_check'
+85 -14
View File
@@ -12,9 +12,79 @@ export interface KeyValueFormValue {
value: string
}
export type ScriptLanguage = 'python' | 'shell' | 'node' | 'lua'
export const SCRIPT_LANGUAGE_META: Record<
ScriptLanguage,
{
label: string
defaultInterpreter: string
defaultScript: string
scriptLabel: string
scriptPlaceholder: string
hint: string
}
> = {
python: {
label: 'Python',
defaultInterpreter: '',
defaultScript: 'main.py',
scriptLabel: '脚本文件或相对路径',
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
hint: '优先使用项目里的 .venv;没有环境时保存任务会自动用 uv 创建。',
},
shell: {
label: 'Shell (cmd)',
defaultInterpreter: 'cmd.exe',
defaultScript: 'run.bat',
scriptLabel: '批处理文件或相对路径',
scriptPlaceholder: 'run.bat 或 scripts\\start.cmd',
hint: '通过 cmd.exe /C 执行批处理文件,不需要虚拟环境。',
},
node: {
label: 'Node.js',
defaultInterpreter: 'node.exe',
defaultScript: 'index.js',
scriptLabel: 'JavaScript 文件或相对路径',
scriptPlaceholder: 'index.js 或 scripts\\worker.js',
hint: '通过 node.exe 执行,不需要虚拟环境;需已安装 Node.js。',
},
lua: {
label: 'Lua',
defaultInterpreter: 'lua.exe',
defaultScript: 'main.lua',
scriptLabel: 'Lua 文件或相对路径',
scriptPlaceholder: 'main.lua 或 scripts\\task.lua',
hint: '通过 lua.exe 执行,不需要虚拟环境;需已安装 Lua。',
},
}
export function detectScriptLanguage(interpreter: string): ScriptLanguage {
const name = interpreter
.trim()
.split(/[\\/]/)
.pop()
?.toLowerCase()
.replace(/\.exe$/, '')
if (name === 'node' || name === 'nodejs') {
return 'node'
}
if (name?.startsWith('lua')) {
return 'lua'
}
if (
name &&
['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh'].includes(name)
) {
return 'shell'
}
return 'python'
}
export interface TaskFormValues {
id: string
name: string
description: string
cron: string
timezone: string
timeout_secs: number
@@ -23,6 +93,7 @@ export interface TaskFormValues {
script: {
working_dir: string
venv: string
language: ScriptLanguage
interpreter: string
script: string
args: KeyValueFormValue[]
@@ -72,6 +143,10 @@ export const taskFormSchema = z
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
description: z
.string()
.trim()
.max(500, '任务说明不能超过 500 个字符'),
cron: z
.string()
.trim()
@@ -88,6 +163,7 @@ export const taskFormSchema = z
script: z.object({
working_dir: z.string(),
venv: z.string(),
language: z.enum(['python', 'shell', 'node', 'lua']),
interpreter: z.string(),
script: z.string(),
args: z.array(keyValueSchema),
@@ -116,13 +192,6 @@ export const taskFormSchema = z
message: '请输入工作目录',
})
}
if (!values.script.interpreter.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'interpreter'],
message: '请输入解释器或可执行程序',
})
}
if (!values.script.script.trim()) {
context.addIssue({
code: 'custom',
@@ -163,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
description: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
@@ -171,7 +241,8 @@ export function createEmptyTaskFormValues(): TaskFormValues {
script: {
working_dir: '',
venv: '',
interpreter: 'python.exe',
language: 'python',
interpreter: '',
script: 'main.py',
args: [],
env: [],
@@ -196,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
@@ -203,9 +275,11 @@ export function taskToFormValues(task: Task): TaskFormValues {
values.actionType = task.action.type
if (task.action.type === 'script') {
const language = task.action.language ?? detectScriptLanguage(task.action.interpreter)
values.script = {
working_dir: task.action.working_dir,
venv: task.action.venv ?? '',
venv: language === 'python' ? (task.action.venv ?? '') : '',
language,
interpreter: task.action.interpreter,
script: task.action.script,
args: argsToRows(task.action.args ?? []),
@@ -245,6 +319,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
type: 'script',
working_dir: values.script.working_dir.trim(),
venv: values.script.venv.trim() || null,
language: values.script.language,
interpreter: values.script.interpreter.trim(),
script: values.script.script.trim(),
args: argumentsToArray(values.script.args),
@@ -291,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 -282
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,10 +43,18 @@ import {
type TaskFilters,
} from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog'
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,
@@ -52,7 +62,7 @@ import {
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
const MAX_TASKS = 100
const ENABLED_OPTIONS = [
{ value: '', label: '全部状态' },
@@ -67,14 +77,12 @@ const ACTION_TYPE_OPTIONS = [
{ value: 'process', label: '进程' },
]
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'name', title: '名称 / 任务标识', width: 250 },
{ key: 'timezone', title: '时区', width: 120 },
{ key: 'schedule', title: '调度周期', width: 210 },
{ key: 'status', title: '状态', width: 110 },
{ key: 'timeout', title: '超时', width: 90 },
{ key: 'actions', title: '操作', align: 'right', width: 260 },
]
/// 列表按任务类型排在一起,类型在卡片里用标签标出。
const TYPE_ORDER: Record<TaskActionType, number> = {
script: 0,
http: 1,
process: 2,
}
export function TaskDashboard() {
const queryClient = useQueryClient()
@@ -82,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)
@@ -107,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({
@@ -131,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: ({
@@ -199,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)
@@ -230,263 +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 (
<main className="app-shell">
<header className="page-header">
<div className="header-title">
<p className="eyebrow">TASKPILOT / WINDOWS SCHEDULER</p>
<Title level={1}>TaskPilot</Title>
<Text tone="sub">Rust 本地轻量化任务引擎 · Windows 原生调度中心</Text>
</div>
<Space gap="sm" wrap>
<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>
</header>
<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}
@@ -502,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)}
@@ -516,7 +722,7 @@ export function TaskDashboard() {
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除任务 ${deleteTarget?.id ?? ''}`}
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不可撤销。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
@@ -529,35 +735,128 @@ export function TaskDashboard() {
}
}}
/>
</main>
</div>
)
}
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 {