feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构

- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320
- 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边
- uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页
- 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量
- 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*,
  运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
This commit is contained in:
2026-11-08 21:26:47 +08:00
parent 37479f48c7
commit 8fdc55e17d
40 changed files with 2421 additions and 1360 deletions
-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
+49 -39
View File
@@ -114,14 +114,14 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
| 分组 | 能力 |
| --- | --- |
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选、分页 |
| 任务管理 | 增删改查、启用/停用、按 ID/名称搜索、按启用状态与 Action 类型筛选 |
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务分组 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
@@ -135,12 +135,11 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
| --- | --- |
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
| `name` | 展示名,1–120 字符 |
| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 |
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
| `enabled` | 是否注册调度 |
| `timeout_secs` | 1–86400;超时终止整个进程树 |
| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) |
| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** |
| `action` | 见下 |
### 三类 Action
@@ -187,7 +186,6 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
- **重试**:字段已持久化但执行器尚未实现自动重试(见「已知限制」)。
---
@@ -231,12 +229,21 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
- 创建时会使用当前默认 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/astral-sh/uv/LatestRelease` 下载当前架构的压缩包,
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去;本机已经装了 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_*`。
@@ -260,23 +267,30 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
## Web 界面
- **顶部品牌栏**:品牌 + 右侧 Rust 引擎在线状态与版本;页面级导航已去掉,配置都收到任务调度页的卡片里。
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + 按类型(脚本 / HTTP / 二进制)分组的任务卡片与分页;
右栏 uv(版本号 / 缓存目录 / Python 目录)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
- **任务卡片**:名称、任务标识、状态、Cron、下次触发,以及运行 / 日志 / 编辑 / 启停 / 删除。
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
页面级导航已去掉,配置都收到任务调度页的卡片里。
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
- **uv 配置弹窗**:图标标题 + 右侧规则提示 + 选项 chip 的版式;「程序目录 uv」与「指定路径」二选一,
程序目录下没有可用 uv 时才出现「下载 uv」;`UV_*` 环境变量键值列表,底部是关闭(图标)/ 保存(整宽)。
uv 来源不读系统 PATH。
- **镜像源弹窗**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
- **虚拟环境弹窗**:Python 版本(uv 下载到 `python\` 的解释器,可填版本下载)+ 托管环境
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包)。
- **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:` 依赖);本仓库只保留页面级布局与
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
@@ -332,7 +346,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器) |
| `GET` | `/api/v1/system/directories?path=` | 列盘符或子文件夹(目录浏览器);`include_files=true` 时一并返回文件 |
| `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
@@ -352,8 +366,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 |
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径和 `extra_env`;`custom` 会校验可运行 |
| `POST` | `/api/v1/uv/download` | 从科大镜像下载当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 |
| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
### 托管虚拟环境
@@ -397,6 +411,7 @@ Content-Type: application/json
{
"id": "sync_data",
"name": "数据同步",
"description": "把上游数据同步到本地仓库,失败时记录日志",
"cron": "0 0 2 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
@@ -447,7 +462,7 @@ Content-Type: application/json
| `cron` | 可解析且存在未来触发点 |
| `timezone` | 有效 IANA 名称 |
| `timeout_secs` | 1–86400 |
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
| `description` | ≤500 字符,可留空 |
| `action.working_dir` / `script` | 非空 |
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
@@ -509,7 +524,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
@@ -518,15 +533,14 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
| 模块 | 职责 |
| --- | --- |
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
| `components/AppHeader.tsx` | 顶部品牌栏、引擎状态 |
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务分组、uv/镜像/环境卡片与三个配置弹窗 |
| `components/UvSettingsSection.tsx` | uv 弹窗内容:来源 chip、下载、`UV_*` 环境变量 |
| `components/PythonRuntimeSection.tsx` | 虚拟环境弹窗内容:Python 版本列表与下载 |
| `components/PythonEnvsSection.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 归一) |
@@ -592,15 +606,12 @@ uv run --with fonttools --with brotli tools/build-fonts.py
| 层 | 覆盖 |
| --- | --- |
| Rust 单元(73) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(48) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
| 前端 Vitest(37) | 三栏工作台渲染、保存 uv 来源与 `UV_*` 环境变量、镜像下载、Python 版本下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Rust 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
> 前端 Vitest 里 6 个用例仍在按已删除的顶部导航点击(uv / 虚拟环境相关),
> 需要改成点右栏卡片并断言弹窗内容后才能全绿;改动停在 UI 调整阶段,尚未修。
---
## 故障排查
@@ -621,8 +632,7 @@ uv run --with fonttools --with brotli tools/build-fonts.py
## 已知限制
- 自动重试尚未实现:`retry_policy` 只校验与存储。
- 并发策略只有 `skip`;没有全局并发上限与队列。
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
- Conda 环境不会自动激活,需要显式填写解释器路径。
-28
View File
@@ -1,28 +0,0 @@
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
CHECK (concurrency_policy = 'skip'),
retry_max_attempts INTEGER NOT NULL DEFAULT 0
CHECK (retry_max_attempts >= 0),
retry_delay_secs INTEGER NOT NULL DEFAULT 0
CHECK (retry_delay_secs >= 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
+127
View File
@@ -0,0 +1,127 @@
-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。
-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cron TEXT NOT NULL,
timezone TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
action_type TEXT NOT NULL
CHECK (action_type IN ('script', 'http', 'process')),
action_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
ON tasks(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON tasks(enabled);
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
ON tasks(action_type);
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。
CREATE TABLE IF NOT EXISTS uv_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
custom_path TEXT,
extra_env TEXT NOT NULL DEFAULT '{}',
mirror_url TEXT,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at)
VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
status TEXT NOT NULL
CHECK (status IN (
'queued', 'running', 'succeeded', 'failed',
'timed_out', 'cancelled', 'skipped'
)),
started_at TEXT,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
error_message TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
ON task_runs(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_task_runs_status
ON task_runs(status);
@@ -1,62 +0,0 @@
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
@@ -1,11 +0,0 @@
-- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed),
-- 或用户自己下载后指定的路径(custom)。只保留一行。
CREATE TABLE IF NOT EXISTS uv_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')),
custom_path TEXT,
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,21 +0,0 @@
-- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
CREATE TABLE uv_settings_v2 (
id INTEGER PRIMARY KEY CHECK (id = 1),
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
custom_path TEXT,
updated_at TEXT NOT NULL
);
INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at)
SELECT id,
CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END,
custom_path,
updated_at
FROM uv_settings;
DROP TABLE uv_settings;
ALTER TABLE uv_settings_v2 RENAME TO uv_settings;
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,2 +0,0 @@
-- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。
ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}';
+3 -6
View File
@@ -45,9 +45,8 @@ use crate::{
state::AppState,
tasks::{
model::{
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
TaskStatsResponse, UpdateTaskRequest,
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
},
routes::{
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
@@ -194,7 +193,7 @@ pub async fn create_python_venv(
tag = "system",
params(ListDirectoriesQuery),
responses(
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
(status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse),
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
)
)]
@@ -336,8 +335,6 @@ pub async fn list_schedule(
TaskActionType,
ScriptLanguage,
HttpMethod,
ConcurrencyPolicy,
RetryPolicy,
TaskAction,
CreateTaskRequest,
UpdateTaskRequest,
+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();
-2
View File
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
pub enum RunTrigger {
Schedule,
Manual,
Retry,
}
impl RunTrigger {
@@ -15,7 +14,6 @@ impl RunTrigger {
match self {
Self::Schedule => "schedule",
Self::Manual => "manual",
Self::Retry => "retry",
}
}
}
-1
View File
@@ -255,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
match value {
"schedule" => Ok(RunTrigger::Schedule),
"manual" => Ok(RunTrigger::Manual),
"retry" => Ok(RunTrigger::Retry),
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
}
}
+2 -3
View File
@@ -198,7 +198,7 @@ fn lock_next_runs(
mod tests {
use chrono::{TimeZone, Timelike};
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
use crate::tasks::model::{ScriptLanguage, TaskAction};
use super::*;
@@ -206,12 +206,11 @@ mod tests {
TaskResponse {
id: "schedule_test".to_owned(),
name: "Schedule test".to_owned(),
description: String::new(),
cron: cron.to_owned(),
timezone: timezone.to_owned(),
enabled: true,
timeout_secs: 30,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy::default(),
action: TaskAction::Script {
working_dir: ".".to_owned(),
venv: None,
+8 -26
View File
@@ -84,22 +84,6 @@ pub enum HttpMethod {
Post,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyPolicy {
#[default]
Skip,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(default)]
pub max_attempts: u32,
#[serde(default)]
pub delay_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskAction {
@@ -176,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
pub struct CreateTaskRequest {
pub id: String,
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -183,10 +170,6 @@ pub struct CreateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -194,6 +177,9 @@ pub struct CreateTaskRequest {
#[serde(deny_unknown_fields)]
pub struct UpdateTaskRequest {
pub name: String,
/// 一句话说明,展示在任务卡片和详情里
#[serde(default)]
pub description: String,
pub cron: String,
#[serde(default = "default_timezone")]
pub timezone: String,
@@ -201,10 +187,6 @@ pub struct UpdateTaskRequest {
pub enabled: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub concurrency_policy: ConcurrencyPolicy,
#[serde(default)]
pub retry_policy: RetryPolicy,
pub action: TaskAction,
}
@@ -212,12 +194,12 @@ pub struct UpdateTaskRequest {
pub struct TaskResponse {
pub id: String,
pub name: String,
/// 一句话说明
pub description: String,
pub cron: String,
pub timezone: String,
pub enabled: bool,
pub timeout_secs: u64,
pub concurrency_policy: ConcurrencyPolicy,
pub retry_policy: RetryPolicy,
pub action: TaskAction,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
+17 -40
View File
@@ -4,15 +4,14 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use crate::{
error::{ApiError, ApiResult},
tasks::model::{
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
TaskListResponse, TaskResponse, UpdateTaskRequest,
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
UpdateTaskRequest,
},
};
const TASK_COLUMNS: &str = "
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
";
#[derive(Debug, Clone, Copy, Default)]
@@ -59,13 +58,11 @@ pub struct ListFilters {
struct TaskRow {
id: String,
name: String,
description: String,
cron: String,
timezone: String,
enabled: bool,
timeout_secs: i64,
concurrency_policy: String,
retry_max_attempts: i64,
retry_delay_secs: i64,
action_type: String,
action_json: String,
created_at: DateTime<Utc>,
@@ -76,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
type Error = ApiError;
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
if row.concurrency_policy != "skip" {
return Err(ApiError::internal(format!(
"unknown concurrency policy: {}",
row.concurrency_policy
)));
}
let action: TaskAction = serde_json::from_str(&row.action_json)?;
if row.action_type != action.action_type().as_str() {
return Err(ApiError::internal(format!(
@@ -94,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
Ok(Self {
id: row.id,
name: row.name,
description: row.description,
cron: row.cron,
timezone: row.timezone,
enabled: row.enabled,
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
concurrency_policy: ConcurrencyPolicy::Skip,
retry_policy: RetryPolicy {
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
},
action,
created_at: row.created_at,
updated_at: row.updated_at,
@@ -115,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"INSERT INTO tasks (
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&request.id)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -149,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
Ok(TaskResponse {
id: request.id.clone(),
name: request.name.clone(),
description: request.description.clone(),
cron: request.cron.clone(),
timezone: request.timezone.clone(),
enabled: request.enabled,
timeout_secs: request.timeout_secs,
concurrency_policy: request.concurrency_policy,
retry_policy: request.retry_policy.clone(),
action: request.action.clone(),
created_at: now,
updated_at: now,
@@ -164,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
let row = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE id = ?",
)
@@ -186,19 +167,16 @@ pub async fn update(
let action_json = serde_json::to_string(&request.action)?;
let result = sqlx::query(
"UPDATE tasks SET
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
action_type = ?, action_json = ?, updated_at = ?
WHERE id = ?",
)
.bind(&request.name)
.bind(&request.description)
.bind(&request.cron)
.bind(&request.timezone)
.bind(request.enabled)
.bind(request.timeout_secs as i64)
.bind("skip")
.bind(i64::from(request.retry_policy.max_attempts))
.bind(request.retry_policy.delay_secs as i64)
.bind(request.action.action_type().as_str())
.bind(action_json)
.bind(now)
@@ -269,9 +247,8 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
let rows = sqlx::query_as::<_, TaskRow>(
"SELECT
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
retry_max_attempts, retry_delay_secs, action_type, action_json,
created_at, updated_at
id, name, description, cron, timezone, enabled, timeout_secs,
action_type, action_json, created_at, updated_at
FROM tasks
WHERE enabled = 1
ORDER BY updated_at DESC, id ASC",
+15 -17
View File
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_DESCRIPTION_LEN: usize = 500;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
@@ -26,17 +27,18 @@ const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_task_id(&request.id)?;
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -56,16 +58,17 @@ pub async fn update(
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.description = request.description.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_description(&request.description)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
@@ -131,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
Ok(())
}
fn validate_description(description: &str) -> ApiResult<()> {
if description.chars().count() > MAX_DESCRIPTION_LEN {
return Err(crate::error::ApiError::validation(
"description",
format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"),
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
@@ -150,18 +162,6 @@ fn validate_common(
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
@@ -371,7 +371,6 @@ mod tests {
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
@@ -384,7 +383,6 @@ mod tests {
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
+190 -13
View File
@@ -15,6 +15,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use url::Url;
use utoipa::ToSchema;
use uuid::Uuid;
@@ -32,11 +33,17 @@ const PYTHON_DIR_DEFAULT: &str = "python";
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";
/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。
const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease";
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 上的版本和可用性
/// 会随启动方式变化,不适合当调度器的隐式依赖。
@@ -72,6 +79,8 @@ 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 {
@@ -80,6 +89,7 @@ impl Default for UvSettings {
source: UvSource::Managed,
custom_path: None,
extra_env: BTreeMap::new(),
mirror_url: None,
}
}
}
@@ -99,8 +109,15 @@ impl UvSettings {
/// 注入给 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)
@@ -114,6 +131,27 @@ impl UvSettings {
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
@@ -244,13 +282,13 @@ fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
}
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
async fn download_and_install() -> Result<String, String> {
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!("{UV_MIRROR}/{}", archive_name());
let url = format!("{}/{}", base.trim_end_matches('/'), archive_name());
let checksum = client
.get(format!("{url}.sha256"))
@@ -312,6 +350,12 @@ pub struct UvSettingsResponse {
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` 为空
@@ -330,6 +374,9 @@ pub struct UpdateUvSettingsRequest {
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
#[serde(default)]
pub extra_env: BTreeMap<String, String>,
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
#[serde(default)]
pub mirror_url: Option<String>,
}
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
@@ -378,10 +425,24 @@ fn describe(settings: &UvSettings) -> UvSettingsResponse {
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(),
env_defaults: UV_PATH_DEFAULTS
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(),
.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,
@@ -460,40 +521,69 @@ fn validate(request: UpdateUvSettingsRequest) -> ApiResult<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)>(
"SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1",
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)) = row else {
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, updated_at)
VALUES (1, ?, ?, ?, ?)
"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?;
@@ -551,12 +641,12 @@ pub async fn update_uv_settings(
)]
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
let version = download_and_install()
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");
let mut settings = load(&state.pool).await?;
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
settings.source = UvSource::Managed;
save(&state.pool, &settings).await?;
@@ -589,6 +679,7 @@ mod tests {
source,
custom_path: custom_path.map(str::to_owned),
extra_env: BTreeMap::new(),
mirror_url: None,
}
}
@@ -598,6 +689,7 @@ mod tests {
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"));
@@ -605,6 +697,7 @@ mod tests {
source: UvSource::Managed,
custom_path: None,
extra_env: BTreeMap::new(),
mirror_url: None,
};
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
}
@@ -638,6 +731,7 @@ mod tests {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
})
.unwrap();
@@ -656,6 +750,7 @@ mod tests {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
});
assert!(result.is_err(), "{name} should be rejected");
}
@@ -667,6 +762,7 @@ mod tests {
source: UvSource::Managed,
custom_path: None,
extra_env,
mirror_url: None,
})
.is_err()
);
@@ -693,6 +789,87 @@ mod tests {
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;
+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;
+75
View File
@@ -197,6 +197,81 @@ async fn uv_settings_mutations_require_token() {
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;
+52 -46
View File
@@ -902,11 +902,20 @@
"schema": {
"type": "string"
}
},
{
"name": "include_files",
"in": "query",
"description": "是否一并返回文件(挑选可执行文件时用);默认只返回目录",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Subdirectories of the requested path, or the drive list",
"description": "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list",
"content": {
"application/json": {
"schema": {
@@ -1465,12 +1474,6 @@
},
"components": {
"schemas": {
"ConcurrencyPolicy": {
"type": "string",
"enum": [
"skip"
]
},
"CreateManagedEnvRequest": {
"type": "object",
"required": [
@@ -1548,12 +1551,13 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明,展示在任务卡片和详情里"
},
"enabled": {
"type": "boolean"
},
@@ -1563,9 +1567,6 @@
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -1620,9 +1621,14 @@
"type": "object",
"required": [
"name",
"path"
"path",
"is_dir"
],
"properties": {
"is_dir": {
"type": "boolean",
"description": "true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)"
},
"name": {
"type": "string",
"description": "用于展示的名称"
@@ -1869,22 +1875,6 @@
}
}
},
"RetryPolicy": {
"type": "object",
"properties": {
"delay_secs": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"max_attempts": {
"type": "integer",
"format": "int32",
"minimum": 0
}
},
"additionalProperties": false
},
"RunStatus": {
"type": "string",
"enum": [
@@ -1901,8 +1891,7 @@
"type": "string",
"enum": [
"schedule",
"manual",
"retry"
"manual"
]
},
"ScheduledTaskResponse": {
@@ -2136,12 +2125,11 @@
"required": [
"id",
"name",
"description",
"cron",
"timezone",
"enabled",
"timeout_secs",
"concurrency_policy",
"retry_policy",
"action",
"created_at",
"updated_at"
@@ -2150,9 +2138,6 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -2160,6 +2145,10 @@
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明"
},
"enabled": {
"type": "boolean"
},
@@ -2169,9 +2158,6 @@
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -2369,21 +2355,19 @@
"action": {
"$ref": "#/components/schemas/TaskAction"
},
"concurrency_policy": {
"$ref": "#/components/schemas/ConcurrencyPolicy"
},
"cron": {
"type": "string"
},
"description": {
"type": "string",
"description": "一句话说明,展示在任务卡片和详情里"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"retry_policy": {
"$ref": "#/components/schemas/RetryPolicy"
},
"timeout_secs": {
"type": "integer",
"format": "int64",
@@ -2417,6 +2401,13 @@
"type": "string"
}
},
"mirror_url": {
"type": [
"string",
"null"
],
"description": "uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认"
},
"source": {
"$ref": "#/components/schemas/UvSource"
}
@@ -2456,6 +2447,9 @@
"managed_dir",
"python_dir",
"cache_dir",
"mirror_url",
"uv_download_url",
"python_mirror_url",
"env_defaults",
"candidates"
],
@@ -2508,13 +2502,25 @@
"type": "string",
"description": "程序目录下的 uv 目录"
},
"mirror_url": {
"type": "string",
"description": "当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认"
},
"python_dir": {
"type": "string",
"description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)"
},
"python_mirror_url": {
"type": "string",
"description": "Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)"
},
"source": {
"$ref": "#/components/schemas/UvSource"
},
"uv_download_url": {
"type": "string",
"description": "uv 自身压缩包的下载地址前缀"
},
"version": {
"type": [
"string",
@@ -15,6 +15,7 @@ Python 任务需要一个可复现的运行环境,但宿主机上的工具链
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`);「虚拟环境」弹窗只负责从已下载列表里选版本创建环境。
## 原因
@@ -26,6 +27,6 @@ Python 任务需要一个可复现的运行环境,但宿主机上的工具链
## 边界
- 托管环境不会自动升级依赖,也不跟随 `pyproject.toml` 变更重建。
- `uv python install` 走 uv 自己的 Python 发行源,不受 PyPI 镜像影响;必要时用 `UV_PYTHON_INSTALL_MIRROR`。
- `uv python install` 走 uv 自己的 Python 发行源(python-build-standalone),和 PyPI 镜像不是一回事;uv 配置弹窗里配「下载镜像」统一给 uv 本体与解释器用,解释器还可以用 `UV_PYTHON_INSTALL_MIRROR` 单独覆盖。
- 删除托管环境是直接删除目录,不可恢复。
- 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。
+405 -176
View File
@@ -48,7 +48,7 @@
.page-container {
width: 100%;
max-width: 1600px;
max-width: 1320px;
min-width: 0;
margin: 0 auto;
}
@@ -139,7 +139,8 @@
}
.toolbar-search {
flex: 1 1 260px;
flex: 1 1 200px;
min-width: 180px;
max-width: 420px;
}
@@ -151,7 +152,7 @@
}
.filter-select {
min-width: 140px;
min-width: 110px;
}
.task-name-cell {
@@ -227,16 +228,6 @@
gap: 4px;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
border-top: 1px solid var(--su-border);
}
/* ---------- 任务编辑器 ---------- */
.task-editor-modal {
@@ -651,6 +642,19 @@
background-color: var(--su-bg-subtle);
}
/* 文件模式下选中的文件 */
.directory-row-selected,
.directory-row-selected:hover {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.directory-check {
margin-left: auto;
flex-shrink: 0;
color: var(--su-color-primary);
}
.directory-icon {
flex-shrink: 0;
color: var(--su-text-muted);
@@ -956,28 +960,6 @@
/* ---------- Python 镜像 ---------- */
.mirror-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.mirror-summary-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.mirror-summary-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.mirror-card {
overflow: hidden;
}
@@ -1197,11 +1179,146 @@
height: 50vh;
flex: none;
}
}
.pagination-bar {
align-items: flex-start;
/* ---------- 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 {
@@ -1231,37 +1348,53 @@
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: 16px;
gap: 24px;
width: 100%;
padding: 0 8px;
max-width: 1320px;
height: 100%;
margin: 0 auto;
padding: 0 16px;
}
.app-header-brand {
.app-header-actions {
display: flex;
flex: none;
align-items: center;
gap: 12px;
min-width: 0;
gap: 4px;
}
.app-header-nav {
display: flex;
align-items: center;
gap: 6px;
}
.app-header-status {
margin-left: auto;
}
/* ---------- 三栏工作台 ---------- */
/* ---------- 三栏工作台:左右各 1 份、中间 2 份(参考站 3/6/3) ---------- */
.dashboard {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
padding: 16px 16px 40px;
@@ -1287,27 +1420,26 @@
.dash-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
gap: 12px 16px;
margin-top: 12px;
}
/* 概览指标:标签与数字在各自格子里居中 */
.dash-metric {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-subtle);
text-align: center;
}
.dash-metric-value {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
}
.dash-metric-danger {
color: var(--su-color-danger);
font-weight: var(--su-font-weight-bold);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.dash-list {
@@ -1345,57 +1477,185 @@
white-space: nowrap;
}
.task-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.task-item {
/* 首页「镜像源」卡片:默认镜像 + 运行时注入的环境变量 */
.mirror-card-summary {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-subtle);
min-width: 0;
gap: 4px;
margin-top: 10px;
}
.task-item-main {
.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: flex-start;
gap: 10px;
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;
}
.task-item-mark {
display: grid;
place-items: center;
.dash-inline-name {
min-width: 0;
}
.dash-inline-time {
flex: none;
width: 32px;
height: 32px;
border: 1px solid var(--su-border);
border-radius: 8px;
background-color: var(--su-bg-card);
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.task-item-text {
.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;
}
.task-item-actions {
/* 右侧调度块: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: 240px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
}
.dashboard-right {
@@ -1414,108 +1674,77 @@
grid-template-columns: minmax(0, 1fr);
}
.task-grid {
grid-template-columns: minmax(0, 1fr);
}
.app-header {
flex-wrap: wrap;
}
}
/* ---------- 左栏用户卡片 ---------- */
.user-card {
/* ---------- 左栏用户卡片:居中的资料卡 ---------- */
.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: 12px;
gap: 6px;
margin-top: 2px;
}
.user-avatar {
position: relative;
flex: none;
}
.user-online {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
border: 2px solid var(--su-bg-card);
border-radius: 50%;
background-color: #22c55e;
}
.user-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.user-dot {
display: inline-block;
.user-profile-status {
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background-color: #22c55e;
background-color: var(--su-color-success);
}
.user-bio {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid var(--su-border);
/* 参考稿是描边胶囊,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: 12px;
line-height: 1.6;
font-size: 13px;
line-height: 1.7;
}
/* 弹窗里的多个区块 */
.modal-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* uv 弹窗:环境变量左标签 + 底部操作 */
/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */
.form-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-section-head {
display: flex;
.user-profile-meta {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 6px;
margin-top: 2px;
}
.form-section-icon {
color: var(--su-text-muted);
.user-profile-engine-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--su-color-success);
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
.user-profile-engine-dot.is-offline {
background-color: var(--su-color-danger);
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
}
.form-actions > button:last-child {
flex: 1;
}
@media (max-width: 720px) {
.form-section-head {
flex-wrap: wrap;
}
}
+175 -39
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',
@@ -53,17 +49,32 @@ interface FetchMockOptions {
}>
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'
@@ -93,7 +104,8 @@ function uvSettingsPayload(
function createFetchMock(options: FetchMockOptions = {}) {
let uvSource = 'managed'
let uvCustomPath: string | null = null
let uvManagedVersion: 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 ?? {}
@@ -192,21 +204,37 @@ function createFetchMock(options: FetchMockOptions = {}) {
if (url.endsWith('/api/v1/uv/download')) {
uvSource = 'managed'
uvManagedVersion = 'uv 0.9.0'
return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion))
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),
uvSettingsPayload(
uvSource,
uvCustomPath,
uvManagedVersion,
uvMirror,
),
)
}
if (url.endsWith('/api/v1/cron/preview')) {
@@ -310,53 +338,91 @@ describe('TaskDashboard', () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
// 任务名会同时出现在卡片和左下角「最近触发」里
expect((await screen.findAllByText('Echo task')).length).toBeGreaterThan(0)
expect(
screen.getByText('Echo the configured message'),
).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
// 顶部品牌栏和用户卡片都会显示引擎状态
expect((await screen.findAllByText('引擎在线')).length).toBeGreaterThan(0)
})
it('navigates to the python settings page from the sidebar', async () => {
it('opens the uv settings dialog from the right column', async () => {
vi.stubGlobal('fetch', createFetchMock())
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('button', { name: 'Python 环境' }),
await screen.findByRole('button', { name: '配置 uv' }),
)
expect(
await screen.findByRole('heading', { name: 'Python 环境' }),
).toBeInTheDocument()
// 镜像和 uv 在同一页
expect(
await screen.findByRole('row', { name: /清华 TUNA/ }),
).toBeInTheDocument()
expect(
await screen.findByText('uv 可执行文件'),
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 executable and env vars picked on the python page', async () => {
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: 'Python 环境' }))
expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument()
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 user.click(
screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }),
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('环境变量名 1'), 'UV_CACHE_DIR')
await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache')
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(
@@ -373,17 +439,63 @@ describe('TaskDashboard', () => {
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
source: 'custom',
custom_path: 'D:\\tools\\uv.exe',
extra_env: { UV_CACHE_DIR: 'D:\\uv-cache' },
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: 'Python 环境' }))
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
await user.click(await screen.findByRole('button', { name: '下载 uv' }))
await waitFor(() => {
@@ -395,16 +507,17 @@ describe('TaskDashboard', () => {
),
).toBe(true)
})
expect((await screen.findAllByText('uv 0.9.0')).length).toBeGreaterThan(0)
expect((await screen.findAllByText('0.9.0')).length).toBeGreaterThan(0)
})
it('downloads a python version with uv', async () => {
const fetchMock = createFetchMock()
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: '虚拟环境' }))
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' }))
@@ -440,8 +553,13 @@ describe('TaskDashboard', () => {
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument()
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')
@@ -493,7 +611,7 @@ describe('TaskDashboard', () => {
const user = userEvent.setup()
renderApp()
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
await user.click(await screen.findByRole('button', { name: '管理' }))
await user.click(await screen.findByRole('button', { name: '查看包' }))
expect(await screen.findByText('numpy')).toBeInTheDocument()
@@ -533,11 +651,29 @@ describe('TaskDashboard', () => {
})
})
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 () => {
+1
View File
@@ -24,6 +24,7 @@ function App() {
<Layout
className="app-root"
header={<AppHeader />}
headerVariant="solid"
maxWidth="full"
contentPadding="none"
>
+6 -1
View File
@@ -311,13 +311,18 @@ export function createPythonVenv(
/**
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
*/
export function listDirectories(
path?: string,
options: { includeFiles?: boolean } = {},
signal?: AbortSignal,
): Promise<DirectoryListingResponse> {
return requestJson<DirectoryListingResponse>(
withQuery('/api/v1/system/directories', { path }),
withQuery('/api/v1/system/directories', {
path,
include_files: options.includeFiles ? 'true' : undefined,
}),
{ signal },
)
}
+20 -16
View File
@@ -456,8 +456,6 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateManagedEnvRequest: {
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
name: string;
@@ -478,12 +476,12 @@ export interface components {
};
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
@@ -498,6 +496,8 @@ export interface components {
timezone: string;
};
DirectoryEntry: {
/** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */
is_dir: boolean;
/** @description 用于展示的名称 */
name: string;
/** @description 绝对路径,选中后回填到表单 */
@@ -574,16 +574,10 @@ export interface components {
/** Format: date-time */
updated_at: string;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
RunTrigger: "schedule" | "manual";
/** @description 已注册调度的任务及其下次触发时间。 */
ScheduledTaskResponse: {
/**
@@ -644,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;
@@ -726,11 +720,11 @@ export interface components {
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
/** @description 一句话说明,展示在任务卡片和详情里 */
description?: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
@@ -741,6 +735,8 @@ export interface components {
extra_env?: {
[key: string]: string;
};
/** @description uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 */
mirror_url?: string | null;
source: components["schemas"]["UvSource"];
};
UvCandidate: {
@@ -767,9 +763,15 @@ export interface components {
};
/** @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;
};
/**
@@ -1553,6 +1555,8 @@ export interface operations {
query?: {
/** @description 绝对目录路径;留空表示列出盘符 */
path?: string;
/** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */
include_files?: boolean;
};
header?: never;
path?: never;
@@ -1560,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;
+46 -31
View File
@@ -1,45 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { Brand, Space, Tag, Text } from '@stash/ui'
import { fetchHealth } from '../api/client'
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'
}
/**
* 顶部导航:品牌 + 引擎状态。
* 顶部栏:左边品牌、右边操作图标。
*
* 配置入口都收进任务调度页的三栏卡片里(uv / 镜像源 / 虚拟环境都用弹窗),
* 所以这里不再放页面级导航。
* 引擎状态、任务数量这些在左栏的用户卡片和「概览」里已经看得到,页头不再重复。
* 明暗切换只写本机偏好(`<html data-scheme>`),刷新会重拉页面数据;
* 「配置 / 退出」暂时没有对应入口,点了给个提示,不做假按钮。
*/
export function AppHeader() {
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => fetchHealth(signal),
refetchInterval: 30_000,
})
const queryClient = useQueryClient()
const [scheme, setScheme] = useState<Scheme>(readScheme)
const fetching = useIsFetching() > 0
const status = healthQuery.isPending
? { tone: 'neutral' as const, label: '检测中' }
: healthQuery.isError
? { tone: 'danger' as const, label: '引擎离线' }
: { tone: 'success' as const, label: '引擎在线' }
useEffect(() => {
document.documentElement.dataset.scheme = scheme
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
}, [scheme])
return (
<div className="app-header">
<div className="app-header-brand">
<Brand name="TaskPilot" mark="TP" />
<Text className="app-header-tagline" size="xs" tone="muted">
本地任务调度 · Windows
</Text>
</div>
<Space className="app-header-status" gap="xs" align="center">
<Tag tone={status.tone} dot>
{status.label}
</Tag>
<Text size="xs" tone="muted">
{healthQuery.data?.version
? `Rust 引擎 v${healthQuery.data.version}`
: '127.0.0.1:3000'}
</Text>
</Space>
<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) => (
{entries.map((entry) => {
const file = entry.is_dir === false
const selected = file && entry.path === selectedFile
return (
<li key={entry.path}>
<button
type="button"
className="directory-row"
onClick={() => navigate(entry.path)}
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="folder_more_line" />
<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}
+10 -9
View File
@@ -54,6 +54,8 @@ function linesToPackages(value: string): string[] {
*
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
*
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
*/
export function PythonEnvsSection() {
const queryClient = useQueryClient()
@@ -149,15 +151,15 @@ export function PythonEnvsSection() {
return (
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs">
<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>
</Space>
</div>
</Card>
<Card padding="none">
@@ -182,9 +184,8 @@ export function PythonEnvsSection() {
</div>
) : envs.length === 0 ? (
<Empty
className="empty-state"
text="还没有预创建的环境"
action={<Button onClick={() => setCreateOpen(true)}>新建环境</Button>}
className="env-manager-empty"
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
/>
) : (
<Table columns={ENV_COLUMNS} rows={rows} />
@@ -293,8 +294,8 @@ export function PythonEnvsSection() {
label="Python 版本(可选)"
hint={
(pythonsQuery.data?.length ?? 0) === 0
? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 uv 自动下载'
: '列表来自 uv 下载到平台目录的版本;留空由 uv 决定'
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
}
>
<Select
@@ -11,7 +11,6 @@ import {
Tag,
Text,
TextButton,
Title,
toast,
type TableColumn,
} from '@stash/ui'
@@ -105,7 +104,6 @@ export function PythonMirrorsSection() {
})
const mirrors = mirrorsQuery.data ?? []
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
const openCreate = () => {
setEditingMirror(null)
@@ -168,7 +166,7 @@ export function PythonMirrorsSection() {
<>
<Card padding="md">
<Space justify="between" align="center" wrap>
<Space direction="column" gap="xs">
<Space direction="column" gap="xs" align="start">
<Text strong>包索引镜像</Text>
<Text size="xs" tone="muted">
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
@@ -178,30 +176,6 @@ export function PythonMirrorsSection() {
</Space>
</Card>
<Card padding="md" className="mirror-summary">
<div className="mirror-summary-main">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Title level={2}>{defaultMirror?.name ?? '未设置默认镜像'}</Title>
<Text className="mono" size="sm" tone="sub">
{defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'}
</Text>
</div>
<div className="mirror-summary-meta">
<Text size="xs" tone="muted">
运行时注入
</Text>
<Space gap="xs" wrap>
<Tag shape="rounded">PIP_INDEX_URL</Tag>
<Tag shape="rounded">UV_INDEX_URL</Tag>
{defaultMirror?.trusted_host ? (
<Tag shape="rounded">PIP_TRUSTED_HOST</Tag>
) : null}
</Space>
</div>
</Card>
<Notice title="镜像只影响任务运行,不会自动安装依赖">
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
优先。修改后对下一次运行生效。
@@ -1,135 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Empty,
Input,
Result,
Space,
Spinner,
Table,
Text,
toast,
type TableColumn,
} from '@stash/ui'
import { useState } from 'react'
import {
getUvSettings,
installPython,
listPythons,
type ManagedPython,
} from '../api/client'
const PYTHON_COLUMNS: TableColumn[] = [
{ key: 'version', title: '版本', width: 160 },
{ key: 'path', title: '解释器路径', width: 480 },
]
const PYTHON_VERSION_HINT = '3.12'
/**
* 「虚拟环境」页的 Python 版本区块:列出 uv 下载到平台目录的解释器,并支持下载新版本。
*
* 下载位置由 `UV_PYTHON_INSTALL_DIR` 决定,默认指向程序目录下的 `python\`。
*/
export function PythonRuntimeSection() {
const queryClient = useQueryClient()
const [version, setVersion] = useState('')
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
})
const uvQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: version.trim() }),
onSuccess: (pythons: ManagedPython[]) => {
setVersion('')
toast.success(`Python 已下载(当前 ${pythons.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const pythons = pythonsQuery.data ?? []
const rows = pythons.map((python) => ({
id: python.version,
version: <Text strong>{python.version}</Text>,
path: (
<code className="code-chip" title={python.path}>
{python.path}
</code>
),
}))
return (
<Card padding="md">
<Space direction="column" gap="md">
<Space direction="column" gap="xs">
<Text strong>Python 版本</Text>
<Text size="xs" tone="muted">
uv 下载的解释器放在{' '}
<Text className="mono">{uvQuery.data?.python_dir ?? 'python\\'}</Text>
(`UV_PYTHON_INSTALL_DIR` 默认指这里);新建环境时从已下载的版本里选。
</Text>
</Space>
<Space gap="xs" wrap align="center">
<Input
value={version}
onChange={(event) => setVersion(event.target.value)}
className="mono-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
onClick={() => {
if (!version.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Result
status="error"
title="Python 列表加载失败"
description={pythonsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void pythonsQuery.refetch()}
>
重试
</Button>
}
/>
) : pythonsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 Python 版本" />
</div>
) : pythons.length === 0 ? (
<Empty className="empty-state" text="还没有下载 Python 版本" />
) : (
<Table columns={PYTHON_COLUMNS} rows={rows} />
)}
</Space>
</Card>
)
}
-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>
)
}
@@ -462,6 +462,21 @@ export function TaskEditorModal({
</FormField>
</div>
<FormField
label="任务说明"
htmlFor="task-description"
hint="一句话说明这个任务做什么,会显示在任务卡片和详情里"
error={errors.description?.message}
>
<Textarea
id="task-description"
{...register('description')}
rows={2}
placeholder="例如:定时请求核心 API,返回非 200 状态码时触发报警"
aria-invalid={Boolean(errors.description)}
/>
</FormField>
<div className="form-grid">
<FormField
label="Cron 表达式"
+601
View File
@@ -0,0 +1,601 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Empty,
FormField,
Icon,
Input,
Modal,
Notice,
RadioGroup,
Result,
Space,
Spinner,
Tabs,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
installPython,
listPythons,
updateUvSettings,
type ManagedPython,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
const PYTHON_VERSION_HINT = '3.12'
type UvTab = 'binary' | 'env' | 'python'
interface EnvRow {
name: string
value: string
}
function rowsFrom(
extraEnv: Record<string, string> | null | undefined,
envDefaults: Record<string, string> | null | undefined,
): EnvRow[] {
const rows = Object.entries(extraEnv ?? {}).map(([name, value]) => ({
name,
value,
}))
// 没显式配置的路径变量显示成默认值那一行(相对程序目录),用户能看见也能改值;
// 这两个变量决定解释器和缓存的位置,界面里不允许删除。
for (const [name, value] of Object.entries(envDefaults ?? {})) {
const configured = rows.some(
(row) => row.name.toUpperCase() === name.toUpperCase(),
)
if (!configured) {
rows.unshift({ name, value })
}
}
return rows
}
function envMap(rows: EnvRow[]): Record<string, string> {
const map: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name && !value) {
continue
}
map[name] = value
}
return map
}
/** `D:\tools\uv.exe` → `D:\tools`;拿不到目录时返回空串(让浏览器从盘符开始)。 */
function directoryOf(path: string): string {
const trimmed = path.trim()
const index = Math.max(
trimmed.lastIndexOf('\\'),
trimmed.lastIndexOf('/'),
)
if (index <= 0) {
return ''
}
const directory = trimmed.slice(0, index)
return directory.endsWith(':') ? `${directory}\\` : directory
}
interface UvSettingsModalProps {
open: boolean
onClose: () => void
}
/**
* uv 配置弹窗:三段配置用标签页切换——可执行文件 / `UV_*` 环境变量 / Python 解释器。
*
* - 来源二选一:程序目录(只读路径 + 缺失时「下载 uv」)或指定路径(可编辑 + 「浏览」挑 exe)。
* - 下载镜像也在这一步:uv 本体与解释器都从它派生;解释器还可以用
* `UV_PYTHON_INSTALL_MIRROR` 单独覆盖(它就在环境变量里)。
* - 环境变量里 `UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` / `UV_PYTHON_INSTALL_MIRROR`
* 是 uv 的关键变量,只能改值、不能删除;其余 `UV_*` 变量照常增删。
* - 解释器由 uv 下载到 `UV_PYTHON_INSTALL_DIR`;虚拟环境弹窗只从这份列表里挑版本。
* - 顶部不再放状态汇总:版本、解释器数量、镜像地址在首页的 uv 卡片里已经有了。
*/
export function UvSettingsModal({ open, onClose }: UvSettingsModalProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
// 解释器列表由 uv 自己列举;uv 不可用时接口会报错,先不请求。
const uvReady = Boolean(settingsQuery.data?.version)
const pythonsQuery = useQuery({
queryKey: ['managed-pythons'],
queryFn: ({ signal }) => listPythons(signal),
enabled: uvReady,
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftMirror, setDraftMirror] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const [pythonVersion, setPythonVersion] = useState('')
const [pickerOpen, setPickerOpen] = useState(false)
const [activeTab, setActiveTab] = useState<UvTab>('binary')
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const mirrorUrl = draftMirror ?? settings?.mirror_url ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const pythons = pythonsQuery.data ?? []
const managedPath = `${settings?.managed_dir ?? 'uv'}\\uv.exe`
const lockedNames = Object.keys(settings?.env_defaults ?? {}).map((name) =>
name.toUpperCase(),
)
const isLockedName = (name: string) =>
lockedNames.includes(name.trim().toUpperCase())
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(null)
setDraftMirror(null)
setDraftEnv(null)
}
const saveMutation = useMutation({
mutationFn: () => {
const extraEnv = envMap(envRows)
// 和默认值一样就不写死,删掉等于回到默认。
for (const [name, value] of Object.entries(settings?.env_defaults ?? {})) {
const configured = Object.keys(extraEnv).some(
(key) => key.toUpperCase() === name.toUpperCase(),
)
if (configured && extraEnv[name]?.trim() === value) {
delete extraEnv[name]
}
}
return updateUvSettings({
source,
custom_path: customPath.trim() || null,
extra_env: extraEnv,
mirror_url: mirrorUrl.trim() || null,
})
},
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
saved.version
? `uv 设置已保存(${shortUvVersion(saved.version)})`
: '已保存 uv 设置,但当前 uv 不可用',
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const downloadMutation = useMutation({
mutationFn: () => downloadUv(),
onSuccess: (saved: UvSettings) => {
resetDrafts()
toast.success(
`已下载 ${shortUvVersion(saved.version) ?? 'uv'} 并切换到程序目录 uv`,
)
void queryClient.invalidateQueries({ queryKey: ['uv-settings'] })
},
onError: (error) => toast.error(error.message),
})
const installMutation = useMutation({
mutationFn: () => installPython({ version: pythonVersion.trim() }),
onSuccess: (installed: ManagedPython[]) => {
setPythonVersion('')
queryClient.setQueryData(['managed-pythons'], installed)
toast.success(`Python 已下载(当前 ${installed.length} 个版本可用)`)
void queryClient.invalidateQueries({ queryKey: ['managed-pythons'] })
},
onError: (error) => toast.error(error.message),
})
const updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const submit = () => {
const seen = new Set<string>()
for (const row of envRows) {
const name = row.name.trim()
if (!name) {
if (row.value.trim()) {
toast.error('环境变量名不能为空')
return
}
continue
}
if (!name.toUpperCase().startsWith('UV_')) {
toast.error(`环境变量名 ${name} 需要以 UV_ 开头`)
return
}
const key = name.toUpperCase()
if (seen.has(key)) {
toast.error(`环境变量 ${name} 重复`)
return
}
seen.add(key)
}
// 两个路径变量决定解释器与缓存的位置,清空会让 uv 找不到目录。
for (const name of lockedNames) {
const row = envRows.find(
(candidate) => candidate.name.trim().toUpperCase() === name,
)
if (!row || !row.value.trim()) {
toast.error(`${name} 不能为空,它决定 uv 的 Python / 缓存目录`)
return
}
}
saveMutation.mutate()
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Modal
open={open}
onClose={onClose}
title="uv 配置"
size="lg"
className="uv-settings-modal"
footer={
<>
<Button variant="secondary" onClick={onClose}>
关闭
</Button>
<Button
type="submit"
form="uv-settings-form"
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存设置
</Button>
</>
}
>
{settingsQuery.isError ? (
<Result
status="error"
title="uv 设置加载失败"
description={settingsQuery.error.message}
actions={
<Button
variant="secondary"
onClick={() => void settingsQuery.refetch()}
>
重试
</Button>
}
/>
) : settingsQuery.isPending ? (
<div className="state-block">
<Spinner label="正在读取 uv 设置" />
<Text tone="sub">正在检测 uv...</Text>
</div>
) : (
<form
id="uv-settings-form"
className="uv-settings"
noValidate
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Tabs
className="uv-tabs"
variant="line"
items={[
{ key: 'binary', label: 'uv 可执行文件' },
{ key: 'env', label: `环境变量(${envRows.length})` },
{ key: 'python', label: `Python 解释器(${pythons.length})` },
]}
activeKey={activeTab}
onChange={(key) => setActiveTab(key as UvTab)}
/>
{activeTab === 'binary' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="uv 可执行文件设置"
>
<RadioGroup
direction="row"
options={SOURCE_OPTIONS}
value={source}
onChange={(value) => setDraftSource(value as UvSource)}
/>
<div className="uv-path-row">
{source === 'managed' ? (
// 程序目录的 uv 是固定位置,只展示、不可编辑;只有「指定路径」能改。
<div className="uv-path-static" title={managedPath}>
<Icon name="lock_line" />
<span className="uv-path-static-text">{managedPath}</span>
</div>
) : (
<Input
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
aria-label="uv 可执行文件"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
)}
{source === 'managed' ? (
uvReady ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)
) : (
<Button
size="sm"
variant="secondary"
onClick={() => setPickerOpen(true)}
>
浏览
</Button>
)}
</div>
<Text size="xs" tone="muted">
{source === 'managed'
? uvReady
? '程序目录里的 uv,随发布目录整体搬走。'
: '没装 uv 时可以从下面的镜像下载当前架构的版本,校验 sha256 后放进程序目录并自动切换。'
: '填 uv.exe 的完整路径,或点「浏览」选择;保存时会校验它能否运行。'}
</Text>
<FormField
label="下载镜像"
htmlFor="uv-mirror-url"
hint="GitHub Release 镜像根:uv 本体与 Python 解释器都从这里下载,留空用内置默认。解释器地址也可以用下面的 UV_PYTHON_INSTALL_MIRROR 单独覆盖。"
>
<Input
id="uv-mirror-url"
value={mirrorUrl}
onChange={(event) => setDraftMirror(event.target.value)}
className="mono-input"
placeholder="https://mirrors.ustc.edu.cn/github-release"
spellCheck={false}
/>
</FormField>
</section>
) : activeTab === 'env' ? (
<section
className="uv-section"
role="tabpanel"
aria-label="UV 环境变量"
>
<div className="uv-panel-head">
<Text size="xs" tone="muted">
共 {envRows.length} 项
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</div>
<Text size="xs" tone="muted">
UV_PYTHON_INSTALL_DIR、UV_CACHE_DIR 决定解释器和缓存放在哪,
UV_PYTHON_INSTALL_MIRROR 决定解释器从哪下载;这三项只能改值、不能删除。
其余 UV_* 变量会注入到每一次 uv 调用,路径类可填相对程序目录的值。
</Text>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => {
const locked = isLockedName(row.name)
const isMirror =
row.name.trim().toUpperCase() ===
'UV_PYTHON_INSTALL_MIRROR'
return (
<div className="uv-env-row" key={index}>
{locked ? (
<span
className="uv-env-locked"
title={
isMirror
? '解释器的下载源,不能删除;改值即可换镜像'
: '决定 uv 的 Python / 缓存目录,不能删除'
}
>
<Icon name="lock_line" />
<span className="uv-env-locked-name">{row.name}</span>
</span>
) : (
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_HTTP_TIMEOUT"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
)}
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder={
locked && !isMirror
? '相对于程序目录,或绝对路径'
: '值'
}
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
{locked ? (
<Text size="xs" tone="muted">
固定
</Text>
) : (
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter(
(_, position) => position !== index,
),
)
}
>
删除
</TextButton>
)}
</div>
)
})}
</div>
)}
</section>
) : (
<section
className="uv-section"
role="tabpanel"
aria-label="Python 解释器设置"
>
<Text size="xs" tone="muted">
由 uv 下载,共 {pythons.length} 个版本。解释器放在{' '}
<Text className="mono" size="xs" tone="sub">
{settings?.python_dir ?? 'python\\'}
</Text>
(UV_PYTHON_INSTALL_DIR);新建虚拟环境时直接选这里的版本,不用重复下载。
</Text>
<Space gap="xs" align="center" wrap>
<Input
value={pythonVersion}
onChange={(event) => setPythonVersion(event.target.value)}
className="mono-input uv-version-input"
placeholder={PYTHON_VERSION_HINT}
aria-label="要下载的 Python 版本"
spellCheck={false}
/>
<Button
variant="secondary"
loading={installMutation.isPending}
disabled={!uvReady}
title={uvReady ? undefined : '请先安装或指定 uv'}
onClick={() => {
if (!pythonVersion.trim()) {
toast.error('请填写要下载的 Python 版本,例如 3.12')
return
}
installMutation.mutate()
}}
>
下载 Python
</Button>
{installMutation.isPending ? (
<Text size="xs" tone="muted">
正在下载,可能需要几十秒...
</Text>
) : null}
</Space>
{pythonsQuery.isError ? (
<Notice
tone="error"
title="解释器列表读取失败"
action={{
label: '重试',
onClick: () => void pythonsQuery.refetch(),
}}
>
{pythonsQuery.error.message}
</Notice>
) : pythonsQuery.isPending ? (
uvReady ? (
<div className="uv-inline-state">
<Spinner size="sm" label="正在读取解释器" />
<Text size="sm" tone="muted">
正在读取解释器...
</Text>
</div>
) : (
<Text size="sm" tone="muted">
先在上面安装或指定 uv,装好之后才能下载解释器。
</Text>
)
) : pythons.length === 0 ? (
<Empty
className="uv-empty"
text="还没有下载解释器。填一个版本号(例如 3.12)后点「下载 Python」。"
/>
) : (
<ul className="uv-python-list">
{pythons.map((python) => (
<li className="uv-python-item" key={python.version}>
<Text strong className="uv-python-version">
{python.version}
</Text>
<code
className="code-chip uv-python-path"
title={python.path}
>
{python.path}
</code>
</li>
))}
</ul>
)}
</section>
)}
</form>
)}
<DirectoryPickerDialog
open={pickerOpen}
initialPath={directoryOf(customPath)}
mode="file"
fileExtensions={['.exe']}
title="选择 uv 可执行文件"
onSelect={(path) => setDraftCustomPath(path)}
onClose={() => setPickerOpen(false)}
/>
</Modal>
)
}
@@ -1,335 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
FormField,
Icon,
IconButton,
Input,
Result,
Space,
Spinner,
Text,
TextButton,
toast,
} from '@stash/ui'
import { useState } from 'react'
import {
downloadUv,
getUvSettings,
updateUvSettings,
type UvSettings,
type UvSource,
} from '../api/client'
import { shortUvVersion } from '../lib/format'
const SOURCE_OPTIONS = [
{ value: 'managed', label: '程序目录 uv' },
{ value: 'custom', label: '指定路径' },
]
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
}
interface UvSettingsSectionProps {
onClose: () => void
}
/**
* uv 配置:可执行文件来源 + 环境变量。
*
* 不读系统 PATH:只用程序目录下的 `uv\uv.exe`(下载或自己放进来),或指定路径。
* 只在「uv 配置」弹窗里渲染,不常驻页面。
*/
export function UvSettingsSection({ onClose }: UvSettingsSectionProps) {
const queryClient = useQueryClient()
const settingsQuery = useQuery({
queryKey: ['uv-settings'],
queryFn: ({ signal }) => getUvSettings(signal),
})
const [draftSource, setDraftSource] = useState<UvSource | null>(null)
const [draftCustomPath, setDraftCustomPath] = useState<string | null>(null)
const [draftEnv, setDraftEnv] = useState<EnvRow[] | null>(null)
const settings = settingsQuery.data
// 编辑过就用草稿,否则跟随服务端当前设置(保存成功后清空草稿重新对齐)。
const source = draftSource ?? settings?.source ?? 'managed'
const customPath = draftCustomPath ?? settings?.custom_path ?? ''
const envRows =
draftEnv ?? rowsFrom(settings?.extra_env, settings?.env_defaults)
const resetDrafts = () => {
setDraftSource(null)
setDraftCustomPath(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,
})
},
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 updateEnvRow = (index: number, patch: Partial<EnvRow>) => {
setDraftEnv(
envRows.map((row, position) =>
position === index ? { ...row, ...patch } : row,
),
)
}
const saveDisabled = settingsQuery.isPending || settingsQuery.isError
return (
<Space direction="column" gap="lg">
{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>
) : (
<>
<div className="form-section">
<div className="form-section-head">
<Space gap="xs" align="center">
<Icon name="chip_line" className="form-section-icon" />
<Text strong>uv 可执行文件</Text>
</Space>
<Text size="xs" tone="muted">
必选 1 项
</Text>
</div>
<div className="chip-row">
{SOURCE_OPTIONS.map((option) => (
<Button
key={option.value}
size="sm"
shape="pill"
variant={source === option.value ? 'primary' : 'secondary'}
onClick={() => setDraftSource(option.value as UvSource)}
>
{option.label}
</Button>
))}
</div>
<Text className="mono" size="xs" tone="sub">
{settings?.version
? `当前 ${shortUvVersion(settings.version)} · ${settings.executable}`
: '未检测到可用的 uv'}
</Text>
{source === 'managed' ? (
<Space gap="xs" align="center" wrap>
<Text
className="mono"
size="xs"
tone="muted"
title={`${settings?.managed_dir ?? 'uv'}\\uv.exe`}
>
{settings?.managed_dir ?? '程序目录'}\uv.exe
</Text>
{settings?.version ? null : (
<Button
size="sm"
variant="secondary"
onClick={() => downloadMutation.mutate()}
loading={downloadMutation.isPending}
>
下载 uv
</Button>
)}
</Space>
) : (
<FormField
label="uv 可执行文件"
htmlFor="uv-custom-path"
hint="填 uv.exe 的完整路径,例如 C:\\Users\\你\\.local\\bin\\uv.exe;保存时会校验它能否运行"
>
<Input
id="uv-custom-path"
value={customPath}
onChange={(event) => setDraftCustomPath(event.target.value)}
className="mono-input"
placeholder="D:\\tools\\uv\\uv.exe"
spellCheck={false}
/>
</FormField>
)}
</div>
<div className="form-section">
<div className="form-section-head">
<Space gap="xs" align="center">
<Icon name="box_line" className="form-section-icon" />
<Text strong>环境变量</Text>
</Space>
<Text size="xs" tone="muted">
可选 · 已设 {envRows.length} 项
</Text>
</div>
<Space justify="between" align="center" wrap>
<Text size="xs" tone="muted">
UV_* 变量(注入到每一次 uv 调用;路径类可填相对程序目录的路径,也可填绝对路径)
</Text>
<TextButton
onClick={() =>
setDraftEnv([...envRows, { name: '', value: '' }])
}
>
+ 添加变量
</TextButton>
</Space>
{envRows.length === 0 ? (
<Text size="sm" tone="muted">
没有额外环境变量
</Text>
) : (
<div className="uv-env-rows">
{envRows.map((row, index) => (
<div className="uv-env-row" key={index}>
<Input
value={row.name}
onChange={(event) =>
updateEnvRow(index, { name: event.target.value })
}
className="mono-input"
placeholder="UV_CACHE_DIR"
spellCheck={false}
aria-label={`环境变量名 ${index + 1}`}
/>
<Input
value={row.value}
onChange={(event) =>
updateEnvRow(index, { value: event.target.value })
}
className="mono-input"
placeholder="D:\\uv-cache"
spellCheck={false}
aria-label={`环境变量值 ${index + 1}`}
/>
<TextButton
tone="danger"
onClick={() =>
setDraftEnv(
envRows.filter((_, position) => position !== index),
)
}
>
删除
</TextButton>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="form-actions">
<IconButton
label="关闭"
icon={<Icon name="close_line" />}
variant="soft"
onClick={onClose}
/>
<Button
block
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={saveDisabled}
>
保存
</Button>
</div>
</Space>
)
}
+8 -5
View File
@@ -84,6 +84,7 @@ export function detectScriptLanguage(interpreter: string): ScriptLanguage {
export interface TaskFormValues {
id: string
name: string
description: string
cron: string
timezone: string
timeout_secs: number
@@ -142,6 +143,10 @@ export const taskFormSchema = z
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
description: z
.string()
.trim()
.max(500, '任务说明不能超过 500 个字符'),
cron: z
.string()
.trim()
@@ -227,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
description: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
@@ -261,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.description = task.description ?? ''
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
@@ -359,15 +366,11 @@ export function valuesToUpdateRequest(
): UpdateTaskRequest {
return {
name: values.name.trim(),
description: values.description.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
+211 -166
View File
@@ -11,7 +11,6 @@ import {
Empty,
Modal,
Notice,
Pagination,
Result,
SearchField,
Select,
@@ -33,6 +32,7 @@ import {
fetchStats,
getUvSettings,
listManagedEnvs,
listPythons,
listPythonMirrors,
listTasks,
runTask,
@@ -45,11 +45,11 @@ import {
import { ConfirmDialog } from '../components/ConfirmDialog'
import { PythonEnvsSection } from '../components/PythonEnvsSection'
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
import { PythonRuntimeSection } from '../components/PythonRuntimeSection'
import { RunLogDrawer } from '../components/RunLogDrawer'
import { EnabledBadge } from '../components/StatusBadge'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { TaskEditorModal } from '../components/TaskEditorModal'
import { UvSettingsSection } from '../components/UvSettingsSection'
import { UvSettingsModal } from '../components/UvSettingsModal'
import {
actionTypeLabel,
formatShortDateTime,
@@ -62,7 +62,7 @@ import {
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
const MAX_TASKS = 100
const ENABLED_OPTIONS = [
{ value: '', label: '全部状态' },
@@ -77,12 +77,12 @@ const ACTION_TYPE_OPTIONS = [
{ value: 'process', label: '进程' },
]
/// 中间栏按任务类型分组,和顶部筛选配合使用。
const TASK_GROUPS: Array<{ type: TaskActionType; label: string }> = [
{ type: 'script', label: '脚本' },
{ type: 'http', label: 'HTTP 请求' },
{ type: 'process', label: '二进制程序' },
]
/// 列表按任务类型排在一起,类型在卡片里用标签标出。
const TYPE_ORDER: Record<TaskActionType, number> = {
script: 0,
http: 1,
process: 2,
}
export function TaskDashboard() {
const queryClient = useQueryClient()
@@ -90,14 +90,13 @@ 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)
@@ -118,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({
@@ -154,6 +153,11 @@ export function TaskDashboard() {
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]),
@@ -162,6 +166,7 @@ export function TaskDashboard() {
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: ({
@@ -227,19 +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 groups = TASK_GROUPS.map((group) => ({
...group,
tasks: tasks.filter((task) => task.action.type === group.type),
})).filter((group) => group.tasks.length > 0)
const nearestRun = [...nextRuns.values()].sort()[0]
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)
@@ -263,21 +274,6 @@ 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)
}
}
return (
<div className="dashboard">
<div className="dashboard-head">
@@ -295,27 +291,43 @@ export function TaskDashboard() {
{/* 左栏:用户 / 概览 / 调度 */}
<aside className="dashboard-column dashboard-left" aria-label="概览">
<Card padding="md">
<div className="user-card">
<div className="user-avatar">
<Avatar fallback="本" size="lg" tone="primary" verified />
<span className="user-online" />
</div>
<div className="user-meta">
<Space gap="xs" align="center" wrap>
<Text strong>本地用户</Text>
<Tag tone="success" shape="rounded">
单机
</Tag>
</Space>
<Text size="xs" tone="muted">
永远本地模式
<span className="user-dot" />
<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>
</div>
<p className="user-bio">
<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
? '离线'
: '在线'}
</Text>
</div>
</Card>
<Card padding="md">
@@ -339,42 +351,36 @@ export function TaskDashboard() {
loading={statsQuery.isPending}
/>
<DashMetric
label="Rust 引擎"
value={
healthQuery.isPending
? '...'
: healthQuery.isError
? '离线'
: '在线'
}
loading={false}
danger={healthQuery.isError}
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>
<div className="dash-kv">
<Text size="xs" tone="muted">
已启用 / 已停用
</Text>
<Text className="mono" size="sm">
{statsQuery.data
? `${statsQuery.data.enabled} / ${statsQuery.data.total - statsQuery.data.enabled}`
: '--'}
</Text>
</div>
<div className="dash-kv">
<Text size="xs" tone="muted">
最近触发
</Text>
<Text size="sm">
{nearestRun ? formatShortDateTime(nearestRun) : '暂无'}
{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>
@@ -388,7 +394,7 @@ export function TaskDashboard() {
onChange={setQueryDraft}
placeholder="搜索任务名称或 ID"
size="md"
maxWidth={360}
maxWidth={420}
/>
<Space gap="xs" wrap align="center">
<Select
@@ -398,7 +404,6 @@ export function TaskDashboard() {
onChange={(value) =>
updateFilters({
enabled: value || undefined,
page: '1',
})
}
/>
@@ -409,21 +414,9 @@ export function TaskDashboard() {
onChange={(value) =>
updateFilters({
action_type: value || undefined,
page: '1',
})
}
/>
{hasFilters ? (
<TextButton onClick={() => setSearchParams({})}>
清除筛选
</TextButton>
) : null}
<TextButton
onClick={() => void refreshAll()}
disabled={refreshing}
>
刷新
</TextButton>
</Space>
</Space>
</Card>
@@ -486,27 +479,13 @@ export function TaskDashboard() {
/>
</Card>
) : (
<>
{groups.map((group) => (
<Card key={group.type} padding="md">
<Space justify="between" align="center">
<Space gap="xs" align="center">
<Text strong>{group.label}</Text>
<Tag shape="rounded">{group.tasks.length}</Tag>
</Space>
<Text size="xs" tone="muted">
{actionTypeLabel(group.type)}
</Text>
</Space>
<div className="task-grid">
{group.tasks.map((task) => (
<TaskItem
sortedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
nextRunAt={nextRuns.get(task.id)}
running={
runMutation.isPending &&
runMutation.variables?.id === task.id
runMutation.isPending && runMutation.variables?.id === task.id
}
toggling={
toggleMutation.isPending &&
@@ -520,24 +499,9 @@ export function TaskDashboard() {
onEdit={() => openEdit(task)}
onToggle={() => toggleMutation.mutate(task)}
onDelete={() => setDeleteTarget(task)}
onDetails={() => setDetailTask(task)}
/>
))}
</div>
</Card>
))}
<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>
</>
))
)}
</section>
@@ -548,17 +512,33 @@ export function TaskDashboard() {
<Text strong size="sm">
uv
</Text>
<TextButton onClick={() => setUvSettingsOpen(true)}>
<TextButton label="配置 uv" onClick={() => setUvSettingsOpen(true)}>
配置
</TextButton>
</Space>
<div className="dash-kv">
<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">
@@ -576,6 +556,19 @@ export function TaskDashboard() {
{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">
@@ -583,17 +576,38 @@ export function TaskDashboard() {
<Text strong size="sm">
镜像源
</Text>
<TextButton onClick={() => setMirrorSettingsOpen(true)}>
<TextButton
label="配置镜像源"
onClick={() => setMirrorSettingsOpen(true)}
>
配置
</TextButton>
</Space>
<div className="dash-kv">
<div className="mirror-card-summary">
<Text size="xs" tone="muted">
当前默认镜像
</Text>
<Text className="mono" size="xs" tone="sub">
{defaultMirror?.index_url ?? '未设置'}
<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>
@@ -627,16 +641,12 @@ export function TaskDashboard() {
</Card>
</aside>
{/* 配置弹窗:uv 与镜像源分开,各自一个弹窗;关闭时不渲染内容,避免重复表单 */}
{/* 配置弹窗:uv(含解释器下载)与镜像源分开,各自一个弹窗 */}
{uvSettingsOpen ? (
<Modal
<UvSettingsModal
open
onClose={() => setUvSettingsOpen(false)}
title="uv 配置"
size="lg"
>
<UvSettingsSection onClose={() => setUvSettingsOpen(false)} />
</Modal>
/>
) : null}
{mirrorSettingsOpen ? (
@@ -645,6 +655,7 @@ export function TaskDashboard() {
onClose={() => setMirrorSettingsOpen(false)}
title="镜像源配置"
size="lg"
className="mirror-settings-modal"
footer={
<Button
variant="secondary"
@@ -673,10 +684,7 @@ export function TaskDashboard() {
</Button>
}
>
<div className="modal-stack">
<PythonRuntimeSection />
<PythonEnvsSection />
</div>
</Modal>
) : null}
@@ -694,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)}
@@ -729,16 +743,15 @@ interface DashMetricProps {
label: string
value: number | string | undefined
loading: boolean
danger?: boolean
}
function DashMetric({ label, value, loading, danger = false }: DashMetricProps) {
function DashMetric({ label, value, loading }: DashMetricProps) {
return (
<div className="dash-metric">
<Text size="xs" tone="muted">
{label}
</Text>
<span className={danger ? 'dash-metric-value dash-metric-danger' : 'dash-metric-value'}>
<span className="dash-metric-value">
{loading ? '...' : (value ?? '--')}
</span>
</div>
@@ -755,9 +768,10 @@ interface TaskItemProps {
onEdit: () => void
onToggle: () => void
onDelete: () => void
onDetails: () => void
}
function TaskItem({
function TaskCard({
task,
nextRunAt,
running,
@@ -767,35 +781,51 @@ function TaskItem({
onEdit,
onToggle,
onDelete,
onDetails,
}: TaskItemProps) {
const mark = task.name.trim().charAt(0).toUpperCase() || 'T'
return (
<div className="task-item">
<div className="task-item-main">
<span className={`task-item-mark task-item-mark-${task.action.type}`}>
{mark}
</span>
<div className="task-item-text">
<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 strong>{task.name}</Text>
<EnabledBadge enabled={task.enabled} />
</Space>
<Text className="task-id" size="xs" tone="muted">
{task.id}
<Text className="task-card-title" size="lg" strong truncate>
{task.name}
</Text>
<Space gap="xs" align="center" wrap>
<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>
<Text size="xs" tone="muted">
<span className="task-card-next">
{task.enabled
? nextRunAt
? `下次 ${formatShortDateTime(nextRunAt)}`
: '调度中…'
: '未调度'}
</Text>
</Space>
</span>
</div>
</div>
<Space className="task-item-actions" gap="xs" wrap justify="end">
<div className="task-card-side">
<Space className="task-card-actions" gap="xs" wrap justify="end">
<TextButton disabled={running} title="立即运行" onClick={onRun}>
{running ? '提交中' : '运行'}
</TextButton>
@@ -809,9 +839,24 @@ function TaskItem({
</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'
}