feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构
- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320 - 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边 - uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页 - 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量 - 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*, 运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
This commit is contained in:
@@ -146,8 +146,6 @@ Task
|
|||||||
├── timezone 默认使用系统时区,可选择 IANA 时区
|
├── timezone 默认使用系统时区,可选择 IANA 时区
|
||||||
├── enabled 是否参与自动调度
|
├── enabled 是否参与自动调度
|
||||||
├── timeout_secs 单次执行超时
|
├── timeout_secs 单次执行超时
|
||||||
├── concurrency_policy 首版固定为 skip
|
|
||||||
├── retry_policy 首版允许 0 次重试,结构上预留
|
|
||||||
├── action 带 type 标签的任务动作
|
├── action 带 type 标签的任务动作
|
||||||
├── created_at
|
├── created_at
|
||||||
└── updated_at
|
└── updated_at
|
||||||
|
|||||||
@@ -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 时区、启动恢复调度、下次触发时间 |
|
| 调度 | 5/6 位 Cron(支持秒级、别名、月份/星期名)、IANA 时区、启动恢复调度、下次触发时间 |
|
||||||
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
|
| 执行 | 手动立即运行;同一任务重叠触发记为 `skipped`;启动时对账遗留运行 |
|
||||||
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
| 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 |
|
||||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||||
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
|
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境(新任务保存时自动创建)、uv 只用程序目录副本或自定义路径(不读系统 PATH)、没有 uv 时可从科大镜像一键下载、`UV_*` 环境变量可配置、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、装包时走默认 PyPI 镜像 |
|
||||||
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务分组 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
| 界面 | 顶部品牌栏 + 三栏工作台(用户/概览/调度 · 任务卡片 · uv/镜像/环境卡片)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
| 交付 | 单进程静态托管、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 字符;同时决定日志目录名 |
|
| `id` | 稳定标识,`[A-Za-z0-9_-]`,1–64 字符;同时决定日志目录名 |
|
||||||
| `name` | 展示名,1–120 字符 |
|
| `name` | 展示名,1–120 字符 |
|
||||||
|
| `description` | 一句话说明,≤500 字符;显示在任务卡片副标题和详情里,可留空 |
|
||||||
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
|
| `cron` | 5 或 6 位表达式,例:`0 0 2 * * *`、`*/30 * * * * *`、`@daily` |
|
||||||
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
|
| `timezone` | IANA 时区,例:`Asia/Shanghai` |
|
||||||
| `enabled` | 是否注册调度 |
|
| `enabled` | 是否注册调度 |
|
||||||
| `timeout_secs` | 1–86400;超时终止整个进程树 |
|
| `timeout_secs` | 1–86400;超时终止整个进程树 |
|
||||||
| `concurrency_policy` | 目前仅 `skip`(同任务重叠触发被跳过) |
|
|
||||||
| `retry_policy` | `max_attempts` ≤ 10、`delay_secs` ≤ 3600;**当前只持久化,不自动重试** |
|
|
||||||
| `action` | 见下 |
|
| `action` | 见下 |
|
||||||
|
|
||||||
### 三类 Action
|
### 三类 Action
|
||||||
@@ -187,7 +186,6 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
|
- **取消后立即重跑**:允许;旧进程终止与新进程启动可能短暂重叠。
|
||||||
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
|
- **错过的触发不补跑**:下次触发始终从"现在"开始计算。
|
||||||
- **下次触发时间**:调度器保存在内存中,`GET /api/v1/schedule` 暴露给前端。
|
- **下次触发时间**:调度器保存在内存中,`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`,不会绕过镜像设置。
|
- 创建时会使用当前默认 PyPI 镜像:注入 `UV_DEFAULT_INDEX`(实际生效的是它)以及兼容用的 `UV_INDEX_URL` / `PIP_INDEX_URL`,不会绕过镜像设置。
|
||||||
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
|
- uv 只用平台认的两份:程序目录下的 `uv\uv.exe`(「程序目录 uv」,默认),或你指定的 `uv.exe`(「指定路径」)。
|
||||||
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
|
**不读系统 PATH**——PATH 上的 uv 版本会随启动方式变化,不适合当调度器的隐式依赖。
|
||||||
没有 uv 时在 uv 配置弹窗点「下载 uv」,从科大镜像
|
没有 uv 时在 uv 配置弹窗点「下载 uv」,从「下载镜像」(默认科大
|
||||||
`mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease` 下载当前架构的压缩包,
|
`mirrors.ustc.edu.cn/github-release`)下载当前架构的压缩包,
|
||||||
校验 sha256 后解压到 `uv\uv.exe` 并自动切换过去;本机已经装了 uv 的话,把它填进「指定路径」即可。
|
校验 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 配置弹窗里还能配置 `UV_*` 环境变量(如 `UV_CACHE_DIR`、`UV_PYTHON_INSTALL_DIR`、`UV_HTTP_TIMEOUT`),
|
||||||
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的
|
会注入到每一次 uv 调用,并优先于镜像注入与进程环境。两个路径变量默认是**相对程序目录**的
|
||||||
`python` / `uv-cache`(随包移动),也可以改成绝对路径。
|
`python` / `uv-cache`(随包移动),也可以改成绝对路径。
|
||||||
|
- Python 解释器的下载与查看也在 uv 配置弹窗里(`uv python install` / `uv python list`)——
|
||||||
|
解释器属于 uv,只是建虚拟环境时要用;虚拟环境弹窗只从这份已下载列表里挑版本。
|
||||||
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。
|
- uv 不可用:保存任务时不会自动建环境,运行时会失败并提示先配置 uv。
|
||||||
- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、弹窗里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX`
|
- 运行任务时会把平台 uv 目录加到 PATH 前面,并注入 `UV_PYTHON_INSTALL_DIR`、弹窗里配置的 `UV_*` 和默认 `UV_DEFAULT_INDEX`
|
||||||
(任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。
|
(任务自己配置的环境变量优先)。这样脚本里直接写 `uv ...` 用的也是平台这一份,装包默认走镜像;不会再注入 `PIP_*`。
|
||||||
@@ -260,23 +267,30 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
|
|
||||||
## Web 界面
|
## Web 界面
|
||||||
|
|
||||||
- **顶部品牌栏**:品牌 + 右侧 Rust 引擎在线状态与版本;页面级导航已去掉,配置都收到任务调度页的卡片里。
|
- **顶部品牌栏**:只保留品牌与副标题;引擎在线状态显示在左栏用户卡片底部(跟随健康检查)。
|
||||||
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + 按类型(脚本 / HTTP / 二进制)分组的任务卡片与分页;
|
页面级导航已去掉,配置都收到任务调度页的卡片里。
|
||||||
右栏 uv(版本号 / 缓存目录 / Python 目录)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
|
- **三栏工作台**(任务调度页):左栏用户卡片 + 概览指标 + 调度摘要;中栏搜索筛选 + **任务卡片**(每个任务一张卡片,一次取最多 100 条,不分页);
|
||||||
- **任务卡片**:名称、任务标识、状态、Cron、下次触发,以及运行 / 日志 / 编辑 / 启停 / 删除。
|
右栏 uv(版本号 / 解释器数量 / 缓存目录 / Python 目录 / 下载镜像)、镜像源、托管虚拟环境摘要——点「配置 / 管理」打开对应**弹窗**。
|
||||||
|
- **任务卡片**:卡片顶部是任务名称(左侧主色竖线 + 整行标题,右上角是「详情」);内容区里是任务标识 + 类型标签、任务说明(没填时回退成执行目标)、Cron 与下次触发时间,以及状态和运行 / 日志 / 编辑 / 启停 / 删除。点「详情」打开只读详情弹窗(基本信息 / 执行配置 / 时间)。
|
||||||
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
||||||
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
|
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
|
||||||
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
|
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
|
||||||
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
缺少虚拟环境但项目有依赖文件时保存会自动用 uv 创建。解释器由语言自动选择,界面不再要求手填。
|
||||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入;
|
||||||
|
uv 的「指定路径」用同一个浏览器选文件(`include_files=true`,只列 `.exe`)。
|
||||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
- **uv 配置弹窗**:图标标题 + 右侧规则提示 + 选项 chip 的版式;「程序目录 uv」与「指定路径」二选一,
|
- **uv 配置弹窗**:顶部状态条(版本 / 可执行文件 / uv 下载与解释器镜像地址 / 解释器与环境变量数量)+ 按配置顺序排的三段。
|
||||||
程序目录下没有可用 uv 时才出现「下载 uv」;`UV_*` 环境变量键值列表,底部是关闭(图标)/ 保存(整宽)。
|
① **uv 可执行文件**:单选「程序目录 uv / 指定路径」——程序目录只展示路径、不可编辑,缺失时才出现「下载 uv」;
|
||||||
uv 来源不读系统 PATH。
|
指定路径是可编辑输入框,右侧「浏览」(页面内文件浏览器挑 `uv.exe`,保存时校验能否运行);下面是「下载镜像」
|
||||||
- **镜像源弹窗**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
(GitHub Release 镜像根,uv 与解释器共用);② **环境变量**:`UV_PYTHON_INSTALL_DIR` / `UV_CACHE_DIR` 决定目录,
|
||||||
只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
|
`UV_PYTHON_INSTALL_MIRROR` 决定解释器下载源,这三项只能改值、不能删除(清空或重复会拦下保存),
|
||||||
- **虚拟环境弹窗**:Python 版本(uv 下载到 `python\` 的解释器,可填版本下载)+ 托管环境
|
其余 `UV_*` 变量可增删;③ **Python 解释器**:按上面配置的目录列出已下载版本,填版本号即下载。
|
||||||
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包)。
|
底部关闭 / 保存设置;uv 来源不读系统 PATH。
|
||||||
|
- **镜像源弹窗**:只放 PyPI 包索引镜像:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;
|
||||||
|
可新增、编辑、启用停用、设为默认。包索引只在**装包**(建环境 / 预装依赖)时生效,任务运行时不注入镜像变量。
|
||||||
|
uv 自己的下载镜像不在这里,在 uv 配置弹窗里。
|
||||||
|
- **虚拟环境弹窗**:只负责**使用**已下载的解释器创建托管环境
|
||||||
|
(`data\envs\<名称>`,从已下载版本里选 Python、可选预装包、查看已装包);解释器下载入口在 uv 配置弹窗。
|
||||||
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
||||||
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
||||||
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
|
业务块(三栏工作台、任务卡片、日志控制台、表单栅格)。
|
||||||
@@ -332,7 +346,7 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
| `POST` | `/api/v1/cron/preview` | 计算未来若干次触发时间 |
|
||||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
|
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的虚拟环境、uv 可用性与依赖文件 |
|
||||||
| `POST` | `/api/v1/python/venv/create` | 用 uv 创建/同步项目 `.venv`(优先复用已有环境) |
|
| `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/stats` | 仪表盘计数(总数/启用/运行中/三类数量) |
|
||||||
| `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 |
|
| `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`、两个路径变量的相对默认值 |
|
| `GET` | `/api/v1/uv` | 当前 uv 来源、可执行文件、版本、`UV_*` 环境变量与解析后的 `python_dir` / `cache_dir`、两个路径变量的相对默认值 |
|
||||||
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径和 `extra_env`;`custom` 会校验可运行 |
|
| `PUT` | `/api/v1/uv` | 保存 uv 来源(`managed` / `custom`)、自定义路径、`extra_env` 和下载镜像根(`mirror_url`,空 = 内置默认);`custom` 会校验可运行 |
|
||||||
| `POST` | `/api/v1/uv/download` | 从科大镜像下载当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
|
| `POST` | `/api/v1/uv/download` | 从下载镜像取当前架构的 uv、校验 sha256、安装到程序目录 `uv\` 并切换过去 |
|
||||||
|
|
||||||
### 托管虚拟环境
|
### 托管虚拟环境
|
||||||
|
|
||||||
@@ -397,6 +411,7 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"id": "sync_data",
|
"id": "sync_data",
|
||||||
"name": "数据同步",
|
"name": "数据同步",
|
||||||
|
"description": "把上游数据同步到本地仓库,失败时记录日志",
|
||||||
"cron": "0 0 2 * * *",
|
"cron": "0 0 2 * * *",
|
||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -447,7 +462,7 @@ Content-Type: application/json
|
|||||||
| `cron` | 可解析且存在未来触发点 |
|
| `cron` | 可解析且存在未来触发点 |
|
||||||
| `timezone` | 有效 IANA 名称 |
|
| `timezone` | 有效 IANA 名称 |
|
||||||
| `timeout_secs` | 1–86400 |
|
| `timeout_secs` | 1–86400 |
|
||||||
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
| `description` | ≤500 字符,可留空 |
|
||||||
| `action.working_dir` / `script` | 非空 |
|
| `action.working_dir` / `script` | 非空 |
|
||||||
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
|
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
|
||||||
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
|
| `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` 扫描、创建/删除、已装包 |
|
| `python_envs.rs` | 托管虚拟环境与 uv Python 版本:`data/envs` 扫描、创建/删除、已装包 |
|
||||||
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
|
| `uv.rs` | uv 来源解析与校验、镜像下载(sha256)、`UV_*` 环境变量与路径默认值 |
|
||||||
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
|
| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 |
|
||||||
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) |
|
| `directory_browser.rs` | 盘符与目录列举(页面内目录选择器,可选返回文件) |
|
||||||
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
| `security.rs` | Origin 白名单、令牌校验、常量时间比较 |
|
||||||
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
| `database.rs` | 连接池与迁移(WAL/busy_timeout) |
|
||||||
|
|
||||||
@@ -518,15 +533,14 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
|
|||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
||||||
| `components/AppHeader.tsx` | 顶部品牌栏、引擎状态 |
|
| `components/AppHeader.tsx` | 顶部品牌栏 |
|
||||||
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务分组、uv/镜像/环境卡片与三个配置弹窗 |
|
| `pages/TaskDashboard.tsx` | 三栏工作台:概览/调度、任务卡片、uv/镜像/环境卡片与三个配置弹窗 |
|
||||||
| `components/UvSettingsSection.tsx` | uv 弹窗内容:来源 chip、下载、`UV_*` 环境变量 |
|
| `components/UvSettingsModal.tsx` | uv 弹窗:状态条、来源选择、下载镜像、Python 解释器下载与列表、`UV_*` 环境变量 |
|
||||||
| `components/PythonRuntimeSection.tsx` | 虚拟环境弹窗内容:Python 版本列表与下载 |
|
| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗:用已下载解释器创建环境、环境列表、已装包 |
|
||||||
| `components/PythonEnvsSection.tsx` | 虚拟环境弹窗内容:环境列表、创建、已装包 |
|
|
||||||
| `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 |
|
| `components/PythonMirrorsSection.tsx` | 镜像源弹窗内容:列表、默认项、新增 / 编辑 / 删除 |
|
||||||
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
|
| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) |
|
||||||
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||||
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器(`mode="file"` 时为文件选择器,用于挑 `uv.exe`) |
|
||||||
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
| `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE |
|
||||||
| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 |
|
| `components/LogViewer.tsx` | 日志虚拟滚动、跟随底部/回到最新 |
|
||||||
| `lib/logLines.ts` | 增量日志的行缓冲(残行拼接、CRLF 归一) |
|
| `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 单元(78) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 来源解析/校验/压缩包解压/镜像地址派生/`UV_*` 环境变量校验、托管环境名校验、PATH 注入去重、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||||
| Rust 集成(48) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
|
| Rust 集成(50) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、uv 设置(含下载镜像)读写与下载鉴权、托管环境校验与 404、venv 探测/创建、无环境运行提示、Shell 脚本执行 |
|
||||||
| 前端 Vitest(37) | 三栏工作台渲染、保存 uv 来源与 `UV_*` 环境变量、镜像下载、Python 版本下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
| 前端 Vitest(40) | 三栏工作台渲染、任务详情弹窗、uv 弹窗(保存来源与 `UV_*`、下载镜像、下载 uv、浏览选 `uv.exe`、下载 Python 版本)、镜像下载、托管环境创建与包列表、新建任务前先建虚拟环境、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||||
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
| Playwright(8) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、目录切换单状态、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
| 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` 只校验与存储。
|
- 同一任务重叠触发时直接记为 `skipped`;没有全局并发上限与队列。
|
||||||
- 并发策略只有 `skip`;没有全局并发上限与队列。
|
|
||||||
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
- HTTP Action 只校验状态码,不支持响应体断言、认证方案或 Header 断言。
|
||||||
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
- 敏感值脱敏基于变量名关键词与值替换,不是凭据保险箱。
|
||||||
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
- Conda 环境不会自动激活,需要显式填写解释器路径。
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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
@@ -45,9 +45,8 @@ use crate::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
tasks::{
|
tasks::{
|
||||||
model::{
|
model::{
|
||||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
|
||||||
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
|
||||||
TaskStatsResponse, UpdateTaskRequest,
|
|
||||||
},
|
},
|
||||||
routes::{
|
routes::{
|
||||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||||
@@ -194,7 +193,7 @@ pub async fn create_python_venv(
|
|||||||
tag = "system",
|
tag = "system",
|
||||||
params(ListDirectoriesQuery),
|
params(ListDirectoriesQuery),
|
||||||
responses(
|
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)
|
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
@@ -336,8 +335,6 @@ pub async fn list_schedule(
|
|||||||
TaskActionType,
|
TaskActionType,
|
||||||
ScriptLanguage,
|
ScriptLanguage,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
ConcurrencyPolicy,
|
|
||||||
RetryPolicy,
|
|
||||||
TaskAction,
|
TaskAction,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
UpdateTaskRequest,
|
UpdateTaskRequest,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500;
|
|||||||
pub struct ListDirectoriesQuery {
|
pub struct ListDirectoriesQuery {
|
||||||
/// 绝对目录路径;留空表示列出盘符
|
/// 绝对目录路径;留空表示列出盘符
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
|
/// 是否一并返回文件(挑选可执行文件时用);默认只返回目录
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_files: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
@@ -22,6 +25,8 @@ pub struct DirectoryEntry {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// 绝对路径,选中后回填到表单
|
/// 绝对路径,选中后回填到表单
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
/// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)
|
||||||
|
pub is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
@@ -79,6 +84,7 @@ pub fn list_directories(
|
|||||||
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
|
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let include_files = query.include_files.unwrap_or(false);
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
for entry in reader.flatten() {
|
for entry in reader.flatten() {
|
||||||
@@ -87,19 +93,26 @@ pub fn list_directories(
|
|||||||
.file_type()
|
.file_type()
|
||||||
.map(|kind| kind.is_dir())
|
.map(|kind| kind.is_dir())
|
||||||
.unwrap_or_else(|_| entry_path.is_dir());
|
.unwrap_or_else(|_| entry_path.is_dir());
|
||||||
if !is_directory {
|
if !is_directory && !include_files {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entries.push(DirectoryEntry {
|
entries.push(DirectoryEntry {
|
||||||
name: entry.file_name().to_string_lossy().into_owned(),
|
name: entry.file_name().to_string_lossy().into_owned(),
|
||||||
path: entry_path.to_string_lossy().into_owned(),
|
path: entry_path.to_string_lossy().into_owned(),
|
||||||
|
is_dir: is_directory,
|
||||||
});
|
});
|
||||||
if entries.len() >= MAX_ENTRIES {
|
if entries.len() >= MAX_ENTRIES {
|
||||||
truncated = true;
|
truncated = true;
|
||||||
break;
|
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 {
|
Ok(DirectoryListingResponse {
|
||||||
path: Some(directory.to_string_lossy().into_owned()),
|
path: Some(directory.to_string_lossy().into_owned()),
|
||||||
@@ -126,6 +139,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
|||||||
DirectoryEntry {
|
DirectoryEntry {
|
||||||
name: format!("{letter}:\\"),
|
name: format!("{letter}:\\"),
|
||||||
path: format!("{letter}:\\"),
|
path: format!("{letter}:\\"),
|
||||||
|
is_dir: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -136,6 +150,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
|||||||
vec![DirectoryEntry {
|
vec![DirectoryEntry {
|
||||||
name: "/".to_owned(),
|
name: "/".to_owned(),
|
||||||
path: "/".to_owned(),
|
path: "/".to_owned(),
|
||||||
|
is_dir: true,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +161,14 @@ mod tests {
|
|||||||
fn query(path: Option<&str>) -> ListDirectoriesQuery {
|
fn query(path: Option<&str>) -> ListDirectoriesQuery {
|
||||||
ListDirectoriesQuery {
|
ListDirectoriesQuery {
|
||||||
path: path.map(str::to_owned),
|
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())
|
.map(|entry| entry.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(names, vec!["Alpha", "zeta"]);
|
assert_eq!(names, vec!["Alpha", "zeta"]);
|
||||||
|
assert!(listing.entries.iter().all(|entry| entry.is_dir));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
listing.parent.as_deref(),
|
listing.parent.as_deref(),
|
||||||
Some(temp.path().parent().unwrap().to_str().unwrap())
|
Some(temp.path().parent().unwrap().to_str().unwrap())
|
||||||
@@ -171,6 +195,26 @@ mod tests {
|
|||||||
assert!(!listing.truncated);
|
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]
|
#[test]
|
||||||
fn returns_absolute_paths_for_entries() {
|
fn returns_absolute_paths_for_entries() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
|
|||||||
pub enum RunTrigger {
|
pub enum RunTrigger {
|
||||||
Schedule,
|
Schedule,
|
||||||
Manual,
|
Manual,
|
||||||
Retry,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RunTrigger {
|
impl RunTrigger {
|
||||||
@@ -15,7 +14,6 @@ impl RunTrigger {
|
|||||||
match self {
|
match self {
|
||||||
Self::Schedule => "schedule",
|
Self::Schedule => "schedule",
|
||||||
Self::Manual => "manual",
|
Self::Manual => "manual",
|
||||||
Self::Retry => "retry",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
|
|||||||
match value {
|
match value {
|
||||||
"schedule" => Ok(RunTrigger::Schedule),
|
"schedule" => Ok(RunTrigger::Schedule),
|
||||||
"manual" => Ok(RunTrigger::Manual),
|
"manual" => Ok(RunTrigger::Manual),
|
||||||
"retry" => Ok(RunTrigger::Retry),
|
|
||||||
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ fn lock_next_runs(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use chrono::{TimeZone, Timelike};
|
use chrono::{TimeZone, Timelike};
|
||||||
|
|
||||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
|
use crate::tasks::model::{ScriptLanguage, TaskAction};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -206,12 +206,11 @@ mod tests {
|
|||||||
TaskResponse {
|
TaskResponse {
|
||||||
id: "schedule_test".to_owned(),
|
id: "schedule_test".to_owned(),
|
||||||
name: "Schedule test".to_owned(),
|
name: "Schedule test".to_owned(),
|
||||||
|
description: String::new(),
|
||||||
cron: cron.to_owned(),
|
cron: cron.to_owned(),
|
||||||
timezone: timezone.to_owned(),
|
timezone: timezone.to_owned(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
timeout_secs: 30,
|
timeout_secs: 30,
|
||||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
|
||||||
retry_policy: RetryPolicy::default(),
|
|
||||||
action: TaskAction::Script {
|
action: TaskAction::Script {
|
||||||
working_dir: ".".to_owned(),
|
working_dir: ".".to_owned(),
|
||||||
venv: None,
|
venv: None,
|
||||||
|
|||||||
@@ -84,22 +84,6 @@ pub enum HttpMethod {
|
|||||||
Post,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum TaskAction {
|
pub enum TaskAction {
|
||||||
@@ -176,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
|
|||||||
pub struct CreateTaskRequest {
|
pub struct CreateTaskRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// 一句话说明,展示在任务卡片和详情里
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
pub cron: String,
|
pub cron: String,
|
||||||
#[serde(default = "default_timezone")]
|
#[serde(default = "default_timezone")]
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
@@ -183,10 +170,6 @@ pub struct CreateTaskRequest {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default = "default_timeout_secs")]
|
#[serde(default = "default_timeout_secs")]
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
#[serde(default)]
|
|
||||||
pub concurrency_policy: ConcurrencyPolicy,
|
|
||||||
#[serde(default)]
|
|
||||||
pub retry_policy: RetryPolicy,
|
|
||||||
pub action: TaskAction,
|
pub action: TaskAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +177,9 @@ pub struct CreateTaskRequest {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct UpdateTaskRequest {
|
pub struct UpdateTaskRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// 一句话说明,展示在任务卡片和详情里
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
pub cron: String,
|
pub cron: String,
|
||||||
#[serde(default = "default_timezone")]
|
#[serde(default = "default_timezone")]
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
@@ -201,10 +187,6 @@ pub struct UpdateTaskRequest {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default = "default_timeout_secs")]
|
#[serde(default = "default_timeout_secs")]
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
#[serde(default)]
|
|
||||||
pub concurrency_policy: ConcurrencyPolicy,
|
|
||||||
#[serde(default)]
|
|
||||||
pub retry_policy: RetryPolicy,
|
|
||||||
pub action: TaskAction,
|
pub action: TaskAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +194,12 @@ pub struct UpdateTaskRequest {
|
|||||||
pub struct TaskResponse {
|
pub struct TaskResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// 一句话说明
|
||||||
|
pub description: String,
|
||||||
pub cron: String,
|
pub cron: String,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub concurrency_policy: ConcurrencyPolicy,
|
|
||||||
pub retry_policy: RetryPolicy,
|
|
||||||
pub action: TaskAction,
|
pub action: TaskAction,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::{ApiError, ApiResult},
|
error::{ApiError, ApiResult},
|
||||||
tasks::model::{
|
tasks::model::{
|
||||||
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
|
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||||
TaskListResponse, TaskResponse, UpdateTaskRequest,
|
UpdateTaskRequest,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TASK_COLUMNS: &str = "
|
const TASK_COLUMNS: &str = "
|
||||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
action_type, action_json, created_at, updated_at
|
||||||
created_at, updated_at
|
|
||||||
";
|
";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
@@ -59,13 +58,11 @@ pub struct ListFilters {
|
|||||||
struct TaskRow {
|
struct TaskRow {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
description: String,
|
||||||
cron: String,
|
cron: String,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
timeout_secs: i64,
|
timeout_secs: i64,
|
||||||
concurrency_policy: String,
|
|
||||||
retry_max_attempts: i64,
|
|
||||||
retry_delay_secs: i64,
|
|
||||||
action_type: String,
|
action_type: String,
|
||||||
action_json: String,
|
action_json: String,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
@@ -76,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
|
|||||||
type Error = ApiError;
|
type Error = ApiError;
|
||||||
|
|
||||||
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
|
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)?;
|
let action: TaskAction = serde_json::from_str(&row.action_json)?;
|
||||||
if row.action_type != action.action_type().as_str() {
|
if row.action_type != action.action_type().as_str() {
|
||||||
return Err(ApiError::internal(format!(
|
return Err(ApiError::internal(format!(
|
||||||
@@ -94,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
cron: row.cron,
|
cron: row.cron,
|
||||||
timezone: row.timezone,
|
timezone: row.timezone,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
|
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,
|
action,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_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 action_json = serde_json::to_string(&request.action)?;
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO tasks (
|
"INSERT INTO tasks (
|
||||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
action_type, action_json, created_at, updated_at
|
||||||
created_at, updated_at
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
)
|
||||||
.bind(&request.id)
|
.bind(&request.id)
|
||||||
.bind(&request.name)
|
.bind(&request.name)
|
||||||
|
.bind(&request.description)
|
||||||
.bind(&request.cron)
|
.bind(&request.cron)
|
||||||
.bind(&request.timezone)
|
.bind(&request.timezone)
|
||||||
.bind(request.enabled)
|
.bind(request.enabled)
|
||||||
.bind(request.timeout_secs as i64)
|
.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(request.action.action_type().as_str())
|
||||||
.bind(action_json)
|
.bind(action_json)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
@@ -149,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
|||||||
Ok(TaskResponse {
|
Ok(TaskResponse {
|
||||||
id: request.id.clone(),
|
id: request.id.clone(),
|
||||||
name: request.name.clone(),
|
name: request.name.clone(),
|
||||||
|
description: request.description.clone(),
|
||||||
cron: request.cron.clone(),
|
cron: request.cron.clone(),
|
||||||
timezone: request.timezone.clone(),
|
timezone: request.timezone.clone(),
|
||||||
enabled: request.enabled,
|
enabled: request.enabled,
|
||||||
timeout_secs: request.timeout_secs,
|
timeout_secs: request.timeout_secs,
|
||||||
concurrency_policy: request.concurrency_policy,
|
|
||||||
retry_policy: request.retry_policy.clone(),
|
|
||||||
action: request.action.clone(),
|
action: request.action.clone(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_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>> {
|
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
|
||||||
let row = sqlx::query_as::<_, TaskRow>(
|
let row = sqlx::query_as::<_, TaskRow>(
|
||||||
"SELECT
|
"SELECT
|
||||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
action_type, action_json, created_at, updated_at
|
||||||
created_at, updated_at
|
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
@@ -186,19 +167,16 @@ pub async fn update(
|
|||||||
let action_json = serde_json::to_string(&request.action)?;
|
let action_json = serde_json::to_string(&request.action)?;
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE tasks SET
|
"UPDATE tasks SET
|
||||||
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||||
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
|
|
||||||
action_type = ?, action_json = ?, updated_at = ?
|
action_type = ?, action_json = ?, updated_at = ?
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&request.name)
|
.bind(&request.name)
|
||||||
|
.bind(&request.description)
|
||||||
.bind(&request.cron)
|
.bind(&request.cron)
|
||||||
.bind(&request.timezone)
|
.bind(&request.timezone)
|
||||||
.bind(request.enabled)
|
.bind(request.enabled)
|
||||||
.bind(request.timeout_secs as i64)
|
.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(request.action.action_type().as_str())
|
||||||
.bind(action_json)
|
.bind(action_json)
|
||||||
.bind(now)
|
.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>> {
|
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||||
let rows = sqlx::query_as::<_, TaskRow>(
|
let rows = sqlx::query_as::<_, TaskRow>(
|
||||||
"SELECT
|
"SELECT
|
||||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
action_type, action_json, created_at, updated_at
|
||||||
created_at, updated_at
|
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE enabled = 1
|
WHERE enabled = 1
|
||||||
ORDER BY updated_at DESC, id ASC",
|
ORDER BY updated_at DESC, id ASC",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
|
|||||||
|
|
||||||
const MAX_TASK_ID_LEN: usize = 64;
|
const MAX_TASK_ID_LEN: usize = 64;
|
||||||
const MAX_TASK_NAME_LEN: usize = 120;
|
const MAX_TASK_NAME_LEN: usize = 120;
|
||||||
|
const MAX_DESCRIPTION_LEN: usize = 500;
|
||||||
const MAX_TIMEOUT_SECS: u64 = 86_400;
|
const MAX_TIMEOUT_SECS: u64 = 86_400;
|
||||||
const MAX_ARGS: usize = 256;
|
const MAX_ARGS: usize = 256;
|
||||||
const MAX_ARG_LEN: usize = 2_048;
|
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> {
|
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
|
||||||
request.name = request.name.trim().to_owned();
|
request.name = request.name.trim().to_owned();
|
||||||
|
request.description = request.description.trim().to_owned();
|
||||||
request.cron = request.cron.trim().to_owned();
|
request.cron = request.cron.trim().to_owned();
|
||||||
request.timezone = request.timezone.trim().to_owned();
|
request.timezone = request.timezone.trim().to_owned();
|
||||||
normalize_action(&mut request.action);
|
normalize_action(&mut request.action);
|
||||||
|
|
||||||
validate_task_id(&request.id)?;
|
validate_task_id(&request.id)?;
|
||||||
|
validate_description(&request.description)?;
|
||||||
validate_common(
|
validate_common(
|
||||||
&request.name,
|
&request.name,
|
||||||
&request.cron,
|
&request.cron,
|
||||||
&request.timezone,
|
&request.timezone,
|
||||||
request.timeout_secs,
|
request.timeout_secs,
|
||||||
&request.retry_policy,
|
|
||||||
)?;
|
)?;
|
||||||
validate_action(&request.action)?;
|
validate_action(&request.action)?;
|
||||||
|
|
||||||
@@ -56,16 +58,17 @@ pub async fn update(
|
|||||||
) -> ApiResult<TaskResponse> {
|
) -> ApiResult<TaskResponse> {
|
||||||
validate_task_id(id)?;
|
validate_task_id(id)?;
|
||||||
request.name = request.name.trim().to_owned();
|
request.name = request.name.trim().to_owned();
|
||||||
|
request.description = request.description.trim().to_owned();
|
||||||
request.cron = request.cron.trim().to_owned();
|
request.cron = request.cron.trim().to_owned();
|
||||||
request.timezone = request.timezone.trim().to_owned();
|
request.timezone = request.timezone.trim().to_owned();
|
||||||
normalize_action(&mut request.action);
|
normalize_action(&mut request.action);
|
||||||
|
|
||||||
|
validate_description(&request.description)?;
|
||||||
validate_common(
|
validate_common(
|
||||||
&request.name,
|
&request.name,
|
||||||
&request.cron,
|
&request.cron,
|
||||||
&request.timezone,
|
&request.timezone,
|
||||||
request.timeout_secs,
|
request.timeout_secs,
|
||||||
&request.retry_policy,
|
|
||||||
)?;
|
)?;
|
||||||
validate_action(&request.action)?;
|
validate_action(&request.action)?;
|
||||||
|
|
||||||
@@ -131,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
|
|||||||
Ok(())
|
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(
|
fn validate_common(
|
||||||
name: &str,
|
name: &str,
|
||||||
cron_expression: &str,
|
cron_expression: &str,
|
||||||
timezone: &str,
|
timezone: &str,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
retry_policy: &RetryPolicy,
|
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
|
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
|
||||||
return Err(crate::error::ApiError::validation(
|
return Err(crate::error::ApiError::validation(
|
||||||
@@ -150,18 +162,6 @@ fn validate_common(
|
|||||||
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
|
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(|_| {
|
Tz::from_str(timezone).map_err(|_| {
|
||||||
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
|
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
|
||||||
@@ -371,7 +371,6 @@ mod tests {
|
|||||||
"0 0 2 * * *",
|
"0 0 2 * * *",
|
||||||
"Asia/Shanghai",
|
"Asia/Shanghai",
|
||||||
300,
|
300,
|
||||||
&RetryPolicy::default(),
|
|
||||||
)
|
)
|
||||||
.expect("task fields should be valid");
|
.expect("task fields should be valid");
|
||||||
validate_action(&script_action()).expect("script action should be valid");
|
validate_action(&script_action()).expect("script action should be valid");
|
||||||
@@ -384,7 +383,6 @@ mod tests {
|
|||||||
"*/0 * * * * *",
|
"*/0 * * * * *",
|
||||||
"Asia/Shanghai",
|
"Asia/Shanghai",
|
||||||
300,
|
300,
|
||||||
&RetryPolicy::default(),
|
|
||||||
)
|
)
|
||||||
.expect_err("invalid cron should fail");
|
.expect_err("invalid cron should fail");
|
||||||
|
|
||||||
|
|||||||
+192
-15
@@ -15,6 +15,7 @@ use chrono::Utc;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use url::Url;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -32,11 +33,17 @@ const PYTHON_DIR_DEFAULT: &str = "python";
|
|||||||
const CACHE_DIR_DEFAULT: &str = "uv-cache";
|
const CACHE_DIR_DEFAULT: &str = "uv-cache";
|
||||||
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
|
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
|
||||||
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
|
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
|
||||||
/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。
|
const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR";
|
||||||
const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease";
|
/// 内置的 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_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
|
||||||
const MAX_UV_ENV_ENTRIES: usize = 32;
|
const MAX_UV_ENV_ENTRIES: usize = 32;
|
||||||
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
||||||
|
const MAX_MIRROR_URL_CHARS: usize = 2048;
|
||||||
|
|
||||||
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||||
/// 会随启动方式变化,不适合当调度器的隐式依赖。
|
/// 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||||
@@ -72,6 +79,8 @@ pub struct UvSettings {
|
|||||||
pub source: UvSource,
|
pub source: UvSource,
|
||||||
pub custom_path: Option<String>,
|
pub custom_path: Option<String>,
|
||||||
pub extra_env: BTreeMap<String, String>,
|
pub extra_env: BTreeMap<String, String>,
|
||||||
|
/// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像
|
||||||
|
pub mirror_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UvSettings {
|
impl Default for UvSettings {
|
||||||
@@ -80,6 +89,7 @@ impl Default for UvSettings {
|
|||||||
source: UvSource::Managed,
|
source: UvSource::Managed,
|
||||||
custom_path: None,
|
custom_path: None,
|
||||||
extra_env: BTreeMap::new(),
|
extra_env: BTreeMap::new(),
|
||||||
|
mirror_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,8 +109,15 @@ impl UvSettings {
|
|||||||
|
|
||||||
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
|
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
|
||||||
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
|
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
|
||||||
|
/// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。
|
||||||
pub fn env(&self) -> BTreeMap<String, String> {
|
pub fn env(&self) -> BTreeMap<String, String> {
|
||||||
let mut env = self.extra_env.clone();
|
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 {
|
for (key, default) in UV_PATH_DEFAULTS {
|
||||||
let value = env
|
let value = env
|
||||||
.get(key)
|
.get(key)
|
||||||
@@ -114,6 +131,27 @@ impl UvSettings {
|
|||||||
env
|
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 的目录(解析后的绝对路径)。
|
/// uv 下载 Python 的目录(解析后的绝对路径)。
|
||||||
pub fn python_install_dir(&self) -> PathBuf {
|
pub fn python_install_dir(&self) -> PathBuf {
|
||||||
let value = self
|
let value = self
|
||||||
@@ -244,13 +282,13 @@ fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
|
/// 从镜像下载当前架构的 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()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(600))
|
.timeout(Duration::from_secs(600))
|
||||||
.user_agent("TaskPilot/0.1")
|
.user_agent("TaskPilot/0.1")
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
|
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
|
||||||
let url = format!("{UV_MIRROR}/{}", archive_name());
|
let url = format!("{}/{}", base.trim_end_matches('/'), archive_name());
|
||||||
|
|
||||||
let checksum = client
|
let checksum = client
|
||||||
.get(format!("{url}.sha256"))
|
.get(format!("{url}.sha256"))
|
||||||
@@ -312,6 +350,12 @@ pub struct UvSettingsResponse {
|
|||||||
pub python_dir: String,
|
pub python_dir: String,
|
||||||
/// 实际生效的 uv 缓存目录(绝对路径)
|
/// 实际生效的 uv 缓存目录(绝对路径)
|
||||||
pub cache_dir: String,
|
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>,
|
pub env_defaults: BTreeMap<String, String>,
|
||||||
/// 当前设置指向的 uv;不可用时 `version` 为空
|
/// 当前设置指向的 uv;不可用时 `version` 为空
|
||||||
@@ -330,6 +374,9 @@ pub struct UpdateUvSettingsRequest {
|
|||||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extra_env: BTreeMap<String, String>,
|
pub extra_env: BTreeMap<String, String>,
|
||||||
|
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
|
||||||
|
#[serde(default)]
|
||||||
|
pub mirror_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
|
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
|
||||||
@@ -378,10 +425,24 @@ fn describe(settings: &UvSettings) -> UvSettingsResponse {
|
|||||||
managed_dir: managed_directory.to_string_lossy().into_owned(),
|
managed_dir: managed_directory.to_string_lossy().into_owned(),
|
||||||
python_dir: settings.python_install_dir().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(),
|
cache_dir: settings.cache_dir().to_string_lossy().into_owned(),
|
||||||
env_defaults: UV_PATH_DEFAULTS
|
mirror_url: settings.mirror_base(),
|
||||||
.iter()
|
uv_download_url: settings.uv_download_base(),
|
||||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
python_mirror_url: settings
|
||||||
.collect(),
|
.extra_env
|
||||||
|
.get(PYTHON_MIRROR_ENV)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| settings.python_mirror_url()),
|
||||||
|
env_defaults: {
|
||||||
|
let mut defaults: BTreeMap<String, String> = UV_PATH_DEFAULTS
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||||
|
.collect();
|
||||||
|
defaults.insert(
|
||||||
|
PYTHON_MIRROR_ENV.to_owned(),
|
||||||
|
settings.python_mirror_url(),
|
||||||
|
);
|
||||||
|
defaults
|
||||||
|
},
|
||||||
executable: current.map(|candidate| candidate.path.clone()),
|
executable: current.map(|candidate| candidate.path.clone()),
|
||||||
version: current.and_then(|candidate| candidate.version.clone()),
|
version: current.and_then(|candidate| candidate.version.clone()),
|
||||||
candidates,
|
candidates,
|
||||||
@@ -460,40 +521,69 @@ fn validate(request: UpdateUvSettingsRequest) -> ApiResult<UvSettings> {
|
|||||||
source: request.source,
|
source: request.source,
|
||||||
custom_path,
|
custom_path,
|
||||||
extra_env: validate_extra_env(&request.extra_env)?,
|
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> {
|
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
|
||||||
let row = sqlx::query_as::<_, (String, Option<String>, String)>(
|
let row = sqlx::query_as::<_, (String, Option<String>, String, Option<String>)>(
|
||||||
"SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1",
|
"SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1",
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.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());
|
return Ok(UvSettings::default());
|
||||||
};
|
};
|
||||||
Ok(UvSettings {
|
Ok(UvSettings {
|
||||||
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
|
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
|
||||||
custom_path,
|
custom_path,
|
||||||
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
|
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
|
||||||
|
mirror_url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
|
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
|
||||||
let extra_env = serde_json::to_string(&settings.extra_env)?;
|
let extra_env = serde_json::to_string(&settings.extra_env)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO uv_settings (id, source, custom_path, extra_env, updated_at)
|
"INSERT INTO uv_settings (id, source, custom_path, extra_env, mirror_url, updated_at)
|
||||||
VALUES (1, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
custom_path = excluded.custom_path,
|
custom_path = excluded.custom_path,
|
||||||
extra_env = excluded.extra_env,
|
extra_env = excluded.extra_env,
|
||||||
|
mirror_url = excluded.mirror_url,
|
||||||
updated_at = excluded.updated_at",
|
updated_at = excluded.updated_at",
|
||||||
)
|
)
|
||||||
.bind(settings.source.as_str())
|
.bind(settings.source.as_str())
|
||||||
.bind(settings.custom_path.as_deref())
|
.bind(settings.custom_path.as_deref())
|
||||||
.bind(extra_env)
|
.bind(extra_env)
|
||||||
|
.bind(settings.mirror_url.as_deref())
|
||||||
.bind(Utc::now())
|
.bind(Utc::now())
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -551,12 +641,12 @@ pub async fn update_uv_settings(
|
|||||||
)]
|
)]
|
||||||
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||||
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
|
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
|
||||||
let version = download_and_install()
|
let mut settings = load(&state.pool).await?;
|
||||||
|
let version = download_and_install(&settings.uv_download_base())
|
||||||
.await
|
.await
|
||||||
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
||||||
tracing::info!(%version, "downloaded uv into the managed directory");
|
tracing::info!(%version, "downloaded uv into the managed directory");
|
||||||
|
|
||||||
let mut settings = load(&state.pool).await?;
|
|
||||||
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
|
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
|
||||||
settings.source = UvSource::Managed;
|
settings.source = UvSource::Managed;
|
||||||
save(&state.pool, &settings).await?;
|
save(&state.pool, &settings).await?;
|
||||||
@@ -589,6 +679,7 @@ mod tests {
|
|||||||
source,
|
source,
|
||||||
custom_path: custom_path.map(str::to_owned),
|
custom_path: custom_path.map(str::to_owned),
|
||||||
extra_env: BTreeMap::new(),
|
extra_env: BTreeMap::new(),
|
||||||
|
mirror_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +689,7 @@ mod tests {
|
|||||||
source: UvSource::Custom,
|
source: UvSource::Custom,
|
||||||
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
|
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
|
||||||
extra_env: BTreeMap::new(),
|
extra_env: BTreeMap::new(),
|
||||||
|
mirror_url: None,
|
||||||
};
|
};
|
||||||
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
|
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
|
||||||
|
|
||||||
@@ -605,6 +697,7 @@ mod tests {
|
|||||||
source: UvSource::Managed,
|
source: UvSource::Managed,
|
||||||
custom_path: None,
|
custom_path: None,
|
||||||
extra_env: BTreeMap::new(),
|
extra_env: BTreeMap::new(),
|
||||||
|
mirror_url: None,
|
||||||
};
|
};
|
||||||
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
|
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
|
||||||
}
|
}
|
||||||
@@ -638,6 +731,7 @@ mod tests {
|
|||||||
source: UvSource::Managed,
|
source: UvSource::Managed,
|
||||||
custom_path: None,
|
custom_path: None,
|
||||||
extra_env,
|
extra_env,
|
||||||
|
mirror_url: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -656,6 +750,7 @@ mod tests {
|
|||||||
source: UvSource::Managed,
|
source: UvSource::Managed,
|
||||||
custom_path: None,
|
custom_path: None,
|
||||||
extra_env,
|
extra_env,
|
||||||
|
mirror_url: None,
|
||||||
});
|
});
|
||||||
assert!(result.is_err(), "{name} should be rejected");
|
assert!(result.is_err(), "{name} should be rejected");
|
||||||
}
|
}
|
||||||
@@ -667,6 +762,7 @@ mod tests {
|
|||||||
source: UvSource::Managed,
|
source: UvSource::Managed,
|
||||||
custom_path: None,
|
custom_path: None,
|
||||||
extra_env,
|
extra_env,
|
||||||
|
mirror_url: None,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
@@ -693,6 +789,87 @@ mod tests {
|
|||||||
assert!(parse_sha256("").is_none());
|
assert!(parse_sha256("").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_uv_and_python_urls_from_the_mirror_base() {
|
||||||
|
let settings = UvSettings {
|
||||||
|
mirror_url: Some("https://mirror.example/github-release/".to_owned()),
|
||||||
|
..UvSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(settings.mirror_base(), "https://mirror.example/github-release");
|
||||||
|
assert_eq!(
|
||||||
|
settings.uv_download_base(),
|
||||||
|
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
settings.python_mirror_url(),
|
||||||
|
"https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_the_builtin_mirror() {
|
||||||
|
let settings = UvSettings::default();
|
||||||
|
|
||||||
|
assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE);
|
||||||
|
assert_eq!(
|
||||||
|
settings.uv_download_base(),
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
settings.python_mirror_url(),
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_mirror_urls() {
|
||||||
|
assert_eq!(validate_mirror_url(None).unwrap(), None);
|
||||||
|
assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
validate_mirror_url(Some(
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/".to_owned()
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("https://mirrors.ustc.edu.cn/github-release")
|
||||||
|
);
|
||||||
|
assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err());
|
||||||
|
assert!(validate_mirror_url(Some("not a url".to_owned())).is_err());
|
||||||
|
assert!(validate_mirror_url(Some("https://user:[email protected]".to_owned())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injects_the_python_mirror_and_keeps_user_overrides() {
|
||||||
|
let settings = UvSettings::default();
|
||||||
|
assert_eq!(
|
||||||
|
settings
|
||||||
|
.env()
|
||||||
|
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some(
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut extra_env = BTreeMap::new();
|
||||||
|
extra_env.insert(
|
||||||
|
"UV_PYTHON_INSTALL_MIRROR".to_owned(),
|
||||||
|
"https://my.mirror/python".to_owned(),
|
||||||
|
);
|
||||||
|
let custom = UvSettings {
|
||||||
|
extra_env,
|
||||||
|
..UvSettings::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
custom
|
||||||
|
.env()
|
||||||
|
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("https://my.mirror/python")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_uv_from_the_archive() {
|
fn extracts_uv_from_the_archive() {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn directory_listing_endpoint_rejects_relative_paths() {
|
async fn directory_listing_endpoint_rejects_relative_paths() {
|
||||||
let (app, _temp_dir) = test_app().await;
|
let (app, _temp_dir) = test_app().await;
|
||||||
|
|||||||
@@ -197,6 +197,81 @@ async fn uv_settings_mutations_require_token() {
|
|||||||
assert_eq!(body["code"], "FORBIDDEN");
|
assert_eq!(body["code"], "FORBIDDEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn uv_mirror_is_saved_with_the_settings_and_drives_both_urls() {
|
||||||
|
let (app, _temp_dir) = test_app().await;
|
||||||
|
let token = auth_token(&app).await;
|
||||||
|
|
||||||
|
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
body["mirror_url"],
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["uv_download_url"],
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["python_mirror_url"],
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||||
|
);
|
||||||
|
// 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖
|
||||||
|
assert_eq!(
|
||||||
|
body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"],
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 非法镜像地址会被拒绝
|
||||||
|
let (status, body) = send_json(
|
||||||
|
&app,
|
||||||
|
Method::PUT,
|
||||||
|
"/api/v1/uv",
|
||||||
|
Some(&token),
|
||||||
|
Some(json!({
|
||||||
|
"source": "managed",
|
||||||
|
"mirror_url": "ftp://mirror.example"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["field"], "url");
|
||||||
|
|
||||||
|
// 保存自定义镜像:两个下载地址都由它派生
|
||||||
|
let (status, saved) = send_json(
|
||||||
|
&app,
|
||||||
|
Method::PUT,
|
||||||
|
"/api/v1/uv",
|
||||||
|
Some(&token),
|
||||||
|
Some(json!({
|
||||||
|
"source": "managed",
|
||||||
|
"mirror_url": "https://mirror.example/github-release/"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(saved["mirror_url"], "https://mirror.example/github-release");
|
||||||
|
assert_eq!(
|
||||||
|
saved["uv_download_url"],
|
||||||
|
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 传 null 恢复内置默认
|
||||||
|
let (status, restored) = send_json(
|
||||||
|
&app,
|
||||||
|
Method::PUT,
|
||||||
|
"/api/v1/uv",
|
||||||
|
Some(&token),
|
||||||
|
Some(json!({ "source": "managed", "mirror_url": null })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
restored["mirror_url"],
|
||||||
|
"https://mirrors.ustc.edu.cn/github-release"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn uv_download_requires_token() {
|
async fn uv_download_requires_token() {
|
||||||
let (app, _temp_dir) = test_app().await;
|
let (app, _temp_dir) = test_app().await;
|
||||||
|
|||||||
+52
-46
@@ -902,11 +902,20 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "include_files",
|
||||||
|
"in": "query",
|
||||||
|
"description": "是否一并返回文件(挑选可执行文件时用);默认只返回目录",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -1465,12 +1474,6 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"ConcurrencyPolicy": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"skip"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"CreateManagedEnvRequest": {
|
"CreateManagedEnvRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1548,12 +1551,13 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"$ref": "#/components/schemas/TaskAction"
|
"$ref": "#/components/schemas/TaskAction"
|
||||||
},
|
},
|
||||||
"concurrency_policy": {
|
|
||||||
"$ref": "#/components/schemas/ConcurrencyPolicy"
|
|
||||||
},
|
|
||||||
"cron": {
|
"cron": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "一句话说明,展示在任务卡片和详情里"
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -1563,9 +1567,6 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"retry_policy": {
|
|
||||||
"$ref": "#/components/schemas/RetryPolicy"
|
|
||||||
},
|
|
||||||
"timeout_secs": {
|
"timeout_secs": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -1620,9 +1621,14 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
"path"
|
"path",
|
||||||
|
"is_dir"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"is_dir": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "用于展示的名称"
|
"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": {
|
"RunStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1901,8 +1891,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"schedule",
|
"schedule",
|
||||||
"manual",
|
"manual"
|
||||||
"retry"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ScheduledTaskResponse": {
|
"ScheduledTaskResponse": {
|
||||||
@@ -2136,12 +2125,11 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
"description",
|
||||||
"cron",
|
"cron",
|
||||||
"timezone",
|
"timezone",
|
||||||
"enabled",
|
"enabled",
|
||||||
"timeout_secs",
|
"timeout_secs",
|
||||||
"concurrency_policy",
|
|
||||||
"retry_policy",
|
|
||||||
"action",
|
"action",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at"
|
"updated_at"
|
||||||
@@ -2150,9 +2138,6 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"$ref": "#/components/schemas/TaskAction"
|
"$ref": "#/components/schemas/TaskAction"
|
||||||
},
|
},
|
||||||
"concurrency_policy": {
|
|
||||||
"$ref": "#/components/schemas/ConcurrencyPolicy"
|
|
||||||
},
|
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -2160,6 +2145,10 @@
|
|||||||
"cron": {
|
"cron": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "一句话说明"
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -2169,9 +2158,6 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"retry_policy": {
|
|
||||||
"$ref": "#/components/schemas/RetryPolicy"
|
|
||||||
},
|
|
||||||
"timeout_secs": {
|
"timeout_secs": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2369,21 +2355,19 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"$ref": "#/components/schemas/TaskAction"
|
"$ref": "#/components/schemas/TaskAction"
|
||||||
},
|
},
|
||||||
"concurrency_policy": {
|
|
||||||
"$ref": "#/components/schemas/ConcurrencyPolicy"
|
|
||||||
},
|
|
||||||
"cron": {
|
"cron": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "一句话说明,展示在任务卡片和详情里"
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"retry_policy": {
|
|
||||||
"$ref": "#/components/schemas/RetryPolicy"
|
|
||||||
},
|
|
||||||
"timeout_secs": {
|
"timeout_secs": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2417,6 +2401,13 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mirror_url": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"$ref": "#/components/schemas/UvSource"
|
"$ref": "#/components/schemas/UvSource"
|
||||||
}
|
}
|
||||||
@@ -2456,6 +2447,9 @@
|
|||||||
"managed_dir",
|
"managed_dir",
|
||||||
"python_dir",
|
"python_dir",
|
||||||
"cache_dir",
|
"cache_dir",
|
||||||
|
"mirror_url",
|
||||||
|
"uv_download_url",
|
||||||
|
"python_mirror_url",
|
||||||
"env_defaults",
|
"env_defaults",
|
||||||
"candidates"
|
"candidates"
|
||||||
],
|
],
|
||||||
@@ -2508,13 +2502,25 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "程序目录下的 uv 目录"
|
"description": "程序目录下的 uv 目录"
|
||||||
},
|
},
|
||||||
|
"mirror_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认"
|
||||||
|
},
|
||||||
"python_dir": {
|
"python_dir": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)"
|
"description": "实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)"
|
||||||
},
|
},
|
||||||
|
"python_mirror_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)"
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"$ref": "#/components/schemas/UvSource"
|
"$ref": "#/components/schemas/UvSource"
|
||||||
},
|
},
|
||||||
|
"uv_download_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "uv 自身压缩包的下载地址前缀"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Python 任务需要一个可复现的运行环境,但宿主机上的工具链
|
|||||||
4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。
|
4. **路径默认值相对程序目录**:`UV_PYTHON_INSTALL_DIR` 默认 `python`、`UV_CACHE_DIR` 默认 `uv-cache`,注入前统一按可执行文件目录解析成绝对路径;用户可改成绝对路径。
|
||||||
5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。
|
5. **用户环境优先,缺失才创建**:解析顺序仍是显式 `venv` → 解释器路径推断 → 工作目录向上查找;都没有时在**保存任务**时用 uv 创建 `.venv`,运行期只校验、不再自动装依赖。
|
||||||
6. **托管环境**:`data/envs/<名称>` 预创建常用环境,Python 版本来自 `uv python install` 下载到平台的列表,可预装包、查看已装包。
|
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` 变更重建。
|
- 托管环境不会自动升级依赖,也不跟随 `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` 等子命令可能与平台预期的参数不一致。
|
- 自定义 uv 若版本过旧,`uv python list/envs` 等子命令可能与平台预期的参数不一致。
|
||||||
|
|||||||
+406
-177
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1600px;
|
max-width: 1320px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-search {
|
.toolbar-search {
|
||||||
flex: 1 1 260px;
|
flex: 1 1 200px;
|
||||||
|
min-width: 180px;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
min-width: 140px;
|
min-width: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-name-cell {
|
.task-name-cell {
|
||||||
@@ -227,16 +228,6 @@
|
|||||||
gap: 4px;
|
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 {
|
.task-editor-modal {
|
||||||
@@ -651,6 +642,19 @@
|
|||||||
background-color: var(--su-bg-subtle);
|
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 {
|
.directory-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--su-text-muted);
|
color: var(--su-text-muted);
|
||||||
@@ -956,28 +960,6 @@
|
|||||||
|
|
||||||
/* ---------- Python 镜像 ---------- */
|
/* ---------- 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 {
|
.mirror-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1197,10 +1179,145 @@
|
|||||||
height: 50vh;
|
height: 50vh;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pagination-bar {
|
/* ---------- uv 配置弹窗 ---------- */
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,37 +1348,53 @@
|
|||||||
padding: 12px 16px;
|
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 {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 8px;
|
max-width: 1320px;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-brand {
|
.app-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-nav {
|
/* ---------- 三栏工作台:左右各 1 份、中间 2 份(参考站 3/6/3) ---------- */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-status {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 三栏工作台 ---------- */
|
|
||||||
.dashboard {
|
.dashboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 16px 16px 40px;
|
padding: 16px 16px 40px;
|
||||||
@@ -1287,27 +1420,26 @@
|
|||||||
.dash-metrics {
|
.dash-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 12px 16px;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 概览指标:标签与数字在各自格子里居中 */
|
||||||
.dash-metric {
|
.dash-metric {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 10px;
|
text-align: center;
|
||||||
border: 1px solid var(--su-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--su-bg-subtle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-metric-value {
|
.dash-metric-value {
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: var(--su-font-weight-bold);
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
.dash-metric-danger {
|
|
||||||
color: var(--su-color-danger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-list {
|
.dash-list {
|
||||||
@@ -1345,57 +1477,185 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-grid {
|
/* 首页「镜像源」卡片:默认镜像 + 运行时注入的环境变量 */
|
||||||
display: grid;
|
.mirror-card-summary {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
padding: 10px;
|
margin-top: 10px;
|
||||||
border: 1px solid var(--su-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--su-bg-subtle);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: baseline;
|
||||||
gap: 10px;
|
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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item-mark {
|
.dash-inline-name {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
place-items: center;
|
}
|
||||||
|
|
||||||
|
.dash-inline-time {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 32px;
|
font-variant-numeric: tabular-nums;
|
||||||
height: 32px;
|
white-space: nowrap;
|
||||||
border: 1px solid var(--su-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--su-bg-card);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item-text {
|
.dash-inline-empty {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 每个任务一张卡片:标题行(任务名 + 类型 + 活跃状态 + 右上角详情)、内容行(说明 · Cron/下次 · 操作) */
|
||||||
|
.task-card-head {
|
||||||
display: flex;
|
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;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
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;
|
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) {
|
@media (max-width: 1200px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-right {
|
.dashboard-right {
|
||||||
@@ -1414,108 +1674,77 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
flex-wrap: wrap;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-profile-status {
|
||||||
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;
|
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin-left: 6px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #22c55e;
|
background-color: var(--su-color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-bio {
|
/* 参考稿是描边胶囊,Tag 默认是浅底,这里补一圈边框 */
|
||||||
margin: 12px 0 0;
|
.user-profile-role {
|
||||||
padding-top: 10px;
|
border: 1px solid var(--su-border-strong);
|
||||||
border-top: 1px solid var(--su-border);
|
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);
|
color: var(--su-text-muted);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹窗里的多个区块 */
|
.user-profile-meta {
|
||||||
.modal-stack {
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* uv 弹窗:环境变量左标签 + 底部操作 */
|
|
||||||
/* 弹窗表单:区块标题 + 选项 chip + 底部操作条 */
|
|
||||||
.form-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
gap: 12px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section-icon {
|
.user-profile-engine-dot {
|
||||||
color: var(--su-text-muted);
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--su-color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-row {
|
.user-profile-engine-dot.is-offline {
|
||||||
display: flex;
|
background-color: var(--su-color-danger);
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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
@@ -9,15 +9,11 @@ import App from './App'
|
|||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: 'echo_task',
|
id: 'echo_task',
|
||||||
name: 'Echo task',
|
name: 'Echo task',
|
||||||
|
description: 'Echo the configured message',
|
||||||
cron: '0 0 2 * * *',
|
cron: '0 0 2 * * *',
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
timeout_secs: 300,
|
timeout_secs: 300,
|
||||||
concurrency_policy: 'skip',
|
|
||||||
retry_policy: {
|
|
||||||
max_attempts: 0,
|
|
||||||
delay_secs: 0,
|
|
||||||
},
|
|
||||||
action: {
|
action: {
|
||||||
type: 'script',
|
type: 'script',
|
||||||
working_dir: 'D:\\Projects\\echo',
|
working_dir: 'D:\\Projects\\echo',
|
||||||
@@ -53,17 +49,32 @@ interface FetchMockOptions {
|
|||||||
}>
|
}>
|
||||||
envPackages?: Record<string, Array<{ name: string; version: string }>>
|
envPackages?: Record<string, Array<{ name: string; version: string }>>
|
||||||
pythons?: Array<{ version: string; path: string }>
|
pythons?: Array<{ version: string; path: string }>
|
||||||
|
/** 模拟「程序目录 uv 已安装」时的版本号,例如 `uv 0.9.0` */
|
||||||
|
uvVersion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function uvSettingsPayload(
|
function uvSettingsPayload(
|
||||||
source: string,
|
source: string,
|
||||||
customPath: string | null,
|
customPath: string | null,
|
||||||
managedVersion: string | null = null,
|
managedVersion: string | null = null,
|
||||||
|
mirrorUrl: string | null = null,
|
||||||
) {
|
) {
|
||||||
|
const mirrorBase = mirrorUrl ?? 'https://mirrors.ustc.edu.cn/github-release'
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
custom_path: customPath,
|
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',
|
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:
|
executable:
|
||||||
source === 'managed'
|
source === 'managed'
|
||||||
? 'D:\\TaskPilot\\uv\\uv.exe'
|
? 'D:\\TaskPilot\\uv\\uv.exe'
|
||||||
@@ -93,7 +104,8 @@ function uvSettingsPayload(
|
|||||||
function createFetchMock(options: FetchMockOptions = {}) {
|
function createFetchMock(options: FetchMockOptions = {}) {
|
||||||
let uvSource = 'managed'
|
let uvSource = 'managed'
|
||||||
let uvCustomPath: string | null = null
|
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 managedEnvs = options.envs ?? []
|
||||||
let managedPythons = options.pythons ?? []
|
let managedPythons = options.pythons ?? []
|
||||||
const managedPackages = options.envPackages ?? {}
|
const managedPackages = options.envPackages ?? {}
|
||||||
@@ -192,21 +204,37 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
|||||||
if (url.endsWith('/api/v1/uv/download')) {
|
if (url.endsWith('/api/v1/uv/download')) {
|
||||||
uvSource = 'managed'
|
uvSource = 'managed'
|
||||||
uvManagedVersion = 'uv 0.9.0'
|
uvManagedVersion = 'uv 0.9.0'
|
||||||
return jsonResponse(uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion))
|
return jsonResponse(
|
||||||
|
uvSettingsPayload(
|
||||||
|
uvSource,
|
||||||
|
uvCustomPath,
|
||||||
|
uvManagedVersion,
|
||||||
|
uvMirror,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (url.endsWith('/api/v1/uv')) {
|
if (url.endsWith('/api/v1/uv')) {
|
||||||
const request = init?.body
|
const request = init?.body
|
||||||
? (JSON.parse(String(init.body)) as {
|
? (JSON.parse(String(init.body)) as {
|
||||||
source: string
|
source: string
|
||||||
custom_path?: string | null
|
custom_path?: string | null
|
||||||
|
mirror_url?: string | null
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
if (request) {
|
if (request) {
|
||||||
uvSource = request.source
|
uvSource = request.source
|
||||||
uvCustomPath = request.custom_path ?? null
|
uvCustomPath = request.custom_path ?? null
|
||||||
|
if (request.mirror_url !== undefined) {
|
||||||
|
uvMirror = request.mirror_url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
uvSettingsPayload(uvSource, uvCustomPath, uvManagedVersion),
|
uvSettingsPayload(
|
||||||
|
uvSource,
|
||||||
|
uvCustomPath,
|
||||||
|
uvManagedVersion,
|
||||||
|
uvMirror,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (url.endsWith('/api/v1/cron/preview')) {
|
if (url.endsWith('/api/v1/cron/preview')) {
|
||||||
@@ -310,53 +338,91 @@ describe('TaskDashboard', () => {
|
|||||||
vi.stubGlobal('fetch', createFetchMock())
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
renderApp()
|
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(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())
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
renderApp()
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
await screen.findByRole('button', { name: 'Python 环境' }),
|
await screen.findByRole('button', { name: '配置 uv' }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'Python 环境' }),
|
await screen.findByRole('heading', { name: 'uv 配置' }),
|
||||||
).toBeInTheDocument()
|
|
||||||
// 镜像和 uv 在同一页
|
|
||||||
expect(
|
|
||||||
await screen.findByRole('row', { name: /清华 TUNA/ }),
|
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(
|
|
||||||
await screen.findByText('uv 可执行文件'),
|
|
||||||
).toBeInTheDocument()
|
).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()
|
const fetchMock = createFetchMock()
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
renderApp()
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Python 环境' }))
|
await user.click(await screen.findByRole('button', { name: '配置 uv' }))
|
||||||
expect(await screen.findByText('未检测到可用的 uv')).toBeInTheDocument()
|
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(
|
await waitFor(() => {
|
||||||
screen.getByRole('radio', { name: '指定自己下载的 uv 路径' }),
|
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(
|
await user.type(
|
||||||
screen.getByLabelText('uv 可执行文件'),
|
screen.getByLabelText('uv 可执行文件'),
|
||||||
'D:\\tools\\uv.exe',
|
'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.click(screen.getByRole('button', { name: '+ 添加变量' }))
|
||||||
await user.type(screen.getByLabelText('环境变量名 1'), 'UV_CACHE_DIR')
|
await user.type(screen.getByLabelText('环境变量名 4'), 'UV_HTTP_TIMEOUT')
|
||||||
await user.type(screen.getByLabelText('环境变量值 1'), 'D:\\uv-cache')
|
await user.type(screen.getByLabelText('环境变量值 4'), '60')
|
||||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
await user.click(screen.getByRole('button', { name: '保存设置' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -373,17 +439,63 @@ describe('TaskDashboard', () => {
|
|||||||
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
|
expect(JSON.parse(String(saved?.[1]?.body))).toEqual({
|
||||||
source: 'custom',
|
source: 'custom',
|
||||||
custom_path: 'D:\\tools\\uv.exe',
|
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 () => {
|
it('downloads uv from the mirror and switches to it', async () => {
|
||||||
const fetchMock = createFetchMock()
|
const fetchMock = createFetchMock()
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
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 user.click(await screen.findByRole('button', { name: '下载 uv' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -395,16 +507,17 @@ describe('TaskDashboard', () => {
|
|||||||
),
|
),
|
||||||
).toBe(true)
|
).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 () => {
|
it('downloads a python version from the uv dialog', async () => {
|
||||||
const fetchMock = createFetchMock()
|
const fetchMock = createFetchMock({ uvVersion: 'uv 0.9.0' })
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
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.type(screen.getByLabelText('要下载的 Python 版本'), '3.12')
|
||||||
await user.click(screen.getByRole('button', { name: '下载 Python' }))
|
await user.click(screen.getByRole('button', { name: '下载 Python' }))
|
||||||
|
|
||||||
@@ -440,8 +553,13 @@ describe('TaskDashboard', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
renderApp()
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: '虚拟环境' }))
|
await user.click(await screen.findByRole('button', { name: '管理' }))
|
||||||
expect(await screen.findByText('还没有预创建的环境')).toBeInTheDocument()
|
const envDialog = await screen.findByRole('dialog', {
|
||||||
|
name: '虚拟环境管理',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await within(envDialog).findByText(/还没有预创建的环境/),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
|
await user.click(screen.getByRole('button', { name: '+ 新建环境' }))
|
||||||
await user.type(screen.getByLabelText('环境名'), 'data-science')
|
await user.type(screen.getByLabelText('环境名'), 'data-science')
|
||||||
@@ -493,7 +611,7 @@ describe('TaskDashboard', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderApp()
|
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: '查看包' }))
|
await user.click(await screen.findByRole('button', { name: '查看包' }))
|
||||||
|
|
||||||
expect(await screen.findByText('numpy')).toBeInTheDocument()
|
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 () => {
|
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||||
vi.stubGlobal('fetch', createFetchMock())
|
vi.stubGlobal('fetch', createFetchMock())
|
||||||
renderApp()
|
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 () => {
|
it('creates a script task with the visible form values', async () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function App() {
|
|||||||
<Layout
|
<Layout
|
||||||
className="app-root"
|
className="app-root"
|
||||||
header={<AppHeader />}
|
header={<AppHeader />}
|
||||||
|
headerVariant="solid"
|
||||||
maxWidth="full"
|
maxWidth="full"
|
||||||
contentPadding="none"
|
contentPadding="none"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -311,13 +311,18 @@ export function createPythonVenv(
|
|||||||
/**
|
/**
|
||||||
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||||
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||||
|
* `includeFiles` 为 true 时一并返回文件(挑选 uv.exe 这类可执行文件时用)。
|
||||||
*/
|
*/
|
||||||
export function listDirectories(
|
export function listDirectories(
|
||||||
path?: string,
|
path?: string,
|
||||||
|
options: { includeFiles?: boolean } = {},
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<DirectoryListingResponse> {
|
): Promise<DirectoryListingResponse> {
|
||||||
return requestJson<DirectoryListingResponse>(
|
return requestJson<DirectoryListingResponse>(
|
||||||
withQuery('/api/v1/system/directories', { path }),
|
withQuery('/api/v1/system/directories', {
|
||||||
|
path,
|
||||||
|
include_files: options.includeFiles ? 'true' : undefined,
|
||||||
|
}),
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,8 +456,6 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
/** @enum {string} */
|
|
||||||
ConcurrencyPolicy: "skip";
|
|
||||||
CreateManagedEnvRequest: {
|
CreateManagedEnvRequest: {
|
||||||
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
|
/** @description 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识 */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -478,12 +476,12 @@ export interface components {
|
|||||||
};
|
};
|
||||||
CreateTaskRequest: {
|
CreateTaskRequest: {
|
||||||
action: components["schemas"]["TaskAction"];
|
action: components["schemas"]["TaskAction"];
|
||||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
|
||||||
cron: string;
|
cron: string;
|
||||||
|
/** @description 一句话说明,展示在任务卡片和详情里 */
|
||||||
|
description?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
retry_policy?: components["schemas"]["RetryPolicy"];
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
timeout_secs?: number;
|
timeout_secs?: number;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
@@ -498,6 +496,8 @@ export interface components {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
DirectoryEntry: {
|
DirectoryEntry: {
|
||||||
|
/** @description true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现) */
|
||||||
|
is_dir: boolean;
|
||||||
/** @description 用于展示的名称 */
|
/** @description 用于展示的名称 */
|
||||||
name: string;
|
name: string;
|
||||||
/** @description 绝对路径,选中后回填到表单 */
|
/** @description 绝对路径,选中后回填到表单 */
|
||||||
@@ -574,16 +574,10 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
RetryPolicy: {
|
|
||||||
/** Format: int64 */
|
|
||||||
delay_secs?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
max_attempts?: number;
|
|
||||||
};
|
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
|
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
RunTrigger: "schedule" | "manual" | "retry";
|
RunTrigger: "schedule" | "manual";
|
||||||
/** @description 已注册调度的任务及其下次触发时间。 */
|
/** @description 已注册调度的任务及其下次触发时间。 */
|
||||||
ScheduledTaskResponse: {
|
ScheduledTaskResponse: {
|
||||||
/**
|
/**
|
||||||
@@ -644,14 +638,14 @@ export interface components {
|
|||||||
};
|
};
|
||||||
TaskResponse: {
|
TaskResponse: {
|
||||||
action: components["schemas"]["TaskAction"];
|
action: components["schemas"]["TaskAction"];
|
||||||
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
|
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
created_at: string;
|
created_at: string;
|
||||||
cron: string;
|
cron: string;
|
||||||
|
/** @description 一句话说明 */
|
||||||
|
description: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
retry_policy: components["schemas"]["RetryPolicy"];
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
timeout_secs: number;
|
timeout_secs: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -726,11 +720,11 @@ export interface components {
|
|||||||
};
|
};
|
||||||
UpdateTaskRequest: {
|
UpdateTaskRequest: {
|
||||||
action: components["schemas"]["TaskAction"];
|
action: components["schemas"]["TaskAction"];
|
||||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
|
||||||
cron: string;
|
cron: string;
|
||||||
|
/** @description 一句话说明,展示在任务卡片和详情里 */
|
||||||
|
description?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
retry_policy?: components["schemas"]["RetryPolicy"];
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
timeout_secs?: number;
|
timeout_secs?: number;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
@@ -741,6 +735,8 @@ export interface components {
|
|||||||
extra_env?: {
|
extra_env?: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
/** @description uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认 */
|
||||||
|
mirror_url?: string | null;
|
||||||
source: components["schemas"]["UvSource"];
|
source: components["schemas"]["UvSource"];
|
||||||
};
|
};
|
||||||
UvCandidate: {
|
UvCandidate: {
|
||||||
@@ -767,9 +763,15 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** @description 程序目录下的 uv 目录 */
|
/** @description 程序目录下的 uv 目录 */
|
||||||
managed_dir: string;
|
managed_dir: string;
|
||||||
|
/** @description 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认 */
|
||||||
|
mirror_url: string;
|
||||||
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
|
/** @description 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析) */
|
||||||
python_dir: string;
|
python_dir: string;
|
||||||
|
/** @description Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`) */
|
||||||
|
python_mirror_url: string;
|
||||||
source: components["schemas"]["UvSource"];
|
source: components["schemas"]["UvSource"];
|
||||||
|
/** @description uv 自身压缩包的下载地址前缀 */
|
||||||
|
uv_download_url: string;
|
||||||
version?: string | null;
|
version?: string | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -1553,6 +1555,8 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
/** @description 绝对目录路径;留空表示列出盘符 */
|
/** @description 绝对目录路径;留空表示列出盘符 */
|
||||||
path?: string;
|
path?: string;
|
||||||
|
/** @description 是否一并返回文件(挑选可执行文件时用);默认只返回目录 */
|
||||||
|
include_files?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1560,7 +1564,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
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: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
|
|||||||
@@ -1,45 +1,60 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Brand, Space, Tag, Text } from '@stash/ui'
|
import { Brand, Icon, IconButton, toast } from '@stash/ui'
|
||||||
import { fetchHealth } from '../api/client'
|
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() {
|
export function AppHeader() {
|
||||||
const healthQuery = useQuery({
|
const queryClient = useQueryClient()
|
||||||
queryKey: ['health'],
|
const [scheme, setScheme] = useState<Scheme>(readScheme)
|
||||||
queryFn: ({ signal }) => fetchHealth(signal),
|
const fetching = useIsFetching() > 0
|
||||||
refetchInterval: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = healthQuery.isPending
|
useEffect(() => {
|
||||||
? { tone: 'neutral' as const, label: '检测中' }
|
document.documentElement.dataset.scheme = scheme
|
||||||
: healthQuery.isError
|
window.localStorage.setItem(THEME_STORAGE_KEY, scheme)
|
||||||
? { tone: 'danger' as const, label: '引擎离线' }
|
}, [scheme])
|
||||||
: { tone: 'success' as const, label: '引擎在线' }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-header">
|
<div className="app-header">
|
||||||
<div className="app-header-brand">
|
<Brand name="TaskPilot" mark="TP" />
|
||||||
<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">
|
<div className="app-header-actions">
|
||||||
<Tag tone={status.tone} dot>
|
<IconButton
|
||||||
{status.label}
|
label={scheme === 'dark' ? '切换到浅色' : '切换到深色'}
|
||||||
</Tag>
|
icon={<Icon name={scheme === 'dark' ? 'sun_line' : 'moon_line'} />}
|
||||||
<Text size="xs" tone="muted">
|
onClick={() => setScheme(scheme === 'dark' ? 'light' : 'dark')}
|
||||||
{healthQuery.data?.version
|
/>
|
||||||
? `Rust 引擎 v${healthQuery.data.version}`
|
<IconButton
|
||||||
: '127.0.0.1:3000'}
|
label="刷新"
|
||||||
</Text>
|
icon={<Icon name="lightning_line" />}
|
||||||
</Space>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ interface DirectoryPickerDialogProps {
|
|||||||
initialPath: string
|
initialPath: string
|
||||||
onSelect: (path: string) => void
|
onSelect: (path: string) => void
|
||||||
onClose: () => 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`,路径输入 / 按钮 / 空态 / 错误提示分别是
|
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
|
||||||
* `Input` / `Button` / `Empty` / `Notice`。
|
* `Input` / `Button` / `Empty` / `Notice`。
|
||||||
*/
|
*/
|
||||||
@@ -31,9 +44,14 @@ export function DirectoryPickerDialog({
|
|||||||
initialPath,
|
initialPath,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
|
mode = 'directory',
|
||||||
|
fileExtensions,
|
||||||
|
title,
|
||||||
}: DirectoryPickerDialogProps) {
|
}: DirectoryPickerDialogProps) {
|
||||||
|
const isFileMode = mode === 'file'
|
||||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||||
const [draftPath, setDraftPath] = useState('')
|
const [draftPath, setDraftPath] = useState('')
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -42,11 +60,17 @@ export function DirectoryPickerDialog({
|
|||||||
const start = initialPath.trim()
|
const start = initialPath.trim()
|
||||||
setCurrentPath(start || null)
|
setCurrentPath(start || null)
|
||||||
setDraftPath(start)
|
setDraftPath(start)
|
||||||
|
setSelectedFile(null)
|
||||||
}, [initialPath, open])
|
}, [initialPath, open])
|
||||||
|
|
||||||
const listing = useQuery({
|
const listing = useQuery({
|
||||||
queryKey: ['directories', currentPath],
|
queryKey: ['directories', currentPath, isFileMode],
|
||||||
queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
listDirectories(
|
||||||
|
currentPath ?? undefined,
|
||||||
|
{ includeFiles: isFileMode },
|
||||||
|
signal,
|
||||||
|
),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
@@ -57,20 +81,30 @@ export function DirectoryPickerDialog({
|
|||||||
}
|
}
|
||||||
}, [listing.data?.path])
|
}, [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 currentDirectory = listing.data?.path ?? null
|
||||||
const parent = listing.data?.parent ?? 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) => {
|
const navigate = (path: string | null) => {
|
||||||
setCurrentPath(path)
|
setCurrentPath(path)
|
||||||
setDraftPath(path ?? '')
|
setDraftPath(path ?? '')
|
||||||
|
setSelectedFile(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="选择工作目录"
|
title={title ?? (isFileMode ? '选择文件' : '选择工作目录')}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="directory-picker-modal"
|
className="directory-picker-modal"
|
||||||
footer={
|
footer={
|
||||||
@@ -80,22 +114,22 @@ export function DirectoryPickerDialog({
|
|||||||
将使用
|
将使用
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" tone="sub" truncate>
|
<Text size="sm" tone="sub" truncate>
|
||||||
{currentDirectory ?? '尚未选择目录'}
|
{chosen ?? (isFileMode ? '尚未选择文件' : '尚未选择目录')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!currentDirectory}
|
disabled={!chosen}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentDirectory) {
|
if (chosen) {
|
||||||
onSelect(currentDirectory)
|
onSelect(chosen)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
使用此目录
|
{isFileMode ? '使用此文件' : '使用此目录'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -148,26 +182,49 @@ export function DirectoryPickerDialog({
|
|||||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
text={
|
text={
|
||||||
currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'
|
currentDirectory
|
||||||
|
? isFileMode
|
||||||
|
? fileExtensions?.length
|
||||||
|
? `该目录下没有 ${fileExtensions.join(' / ')} 文件`
|
||||||
|
: '该目录下没有文件'
|
||||||
|
: '该目录下没有子文件夹'
|
||||||
|
: '没有可用的盘符'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{entries.length > 0 ? (
|
{entries.length > 0 ? (
|
||||||
<ul className="directory-list">
|
<ul className="directory-list">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<li key={entry.path}>
|
const file = entry.is_dir === false
|
||||||
<button
|
const selected = file && entry.path === selectedFile
|
||||||
type="button"
|
|
||||||
className="directory-row"
|
return (
|
||||||
onClick={() => navigate(entry.path)}
|
<li key={entry.path}>
|
||||||
title={entry.path}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon className="directory-icon" name="folder_more_line" />
|
className={
|
||||||
<span className="directory-name">{entry.name}</span>
|
selected
|
||||||
</button>
|
? 'directory-row directory-row-selected'
|
||||||
</li>
|
: 'directory-row'
|
||||||
))}
|
}
|
||||||
|
onClick={() =>
|
||||||
|
file ? setSelectedFile(entry.path) : navigate(entry.path)
|
||||||
|
}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="directory-icon"
|
||||||
|
name={file ? 'box_line' : 'folder_more_line'}
|
||||||
|
/>
|
||||||
|
<span className="directory-name">{entry.name}</span>
|
||||||
|
{selected ? (
|
||||||
|
<Icon className="directory-check" name="check_line" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function linesToPackages(value: string): string[] {
|
|||||||
*
|
*
|
||||||
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
|
* 环境放在 `data/envs/<名称>`,可以预装常用包;任务里把绝对路径填进
|
||||||
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
|
* 「虚拟环境目录」就能复用。列表只读 `pyvenv.cfg`,包列表按需查询。
|
||||||
|
*
|
||||||
|
* 解释器由 uv 下载(入口在「uv 配置」弹窗),这里只负责挑一个版本创建环境。
|
||||||
*/
|
*/
|
||||||
export function PythonEnvsSection() {
|
export function PythonEnvsSection() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -149,15 +151,15 @@ export function PythonEnvsSection() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<Space justify="between" align="center" wrap>
|
<div className="env-manager-head">
|
||||||
<Space direction="column" gap="xs">
|
<Space direction="column" gap="xs" align="start">
|
||||||
<Text strong>虚拟环境</Text>
|
<Text strong>虚拟环境</Text>
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
预创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
|
用已下载的 Python 解释器创建常用环境;任务里把这里的绝对路径填进「虚拟环境目录」即可复用。
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
|
<Button onClick={() => setCreateOpen(true)}>+ 新建环境</Button>
|
||||||
</Space>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
@@ -182,9 +184,8 @@ export function PythonEnvsSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : envs.length === 0 ? (
|
) : envs.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
className="empty-state"
|
className="env-manager-empty"
|
||||||
text="还没有预创建的环境"
|
text="还没有预创建的环境,点右上角「+ 新建环境」创建一个。"
|
||||||
action={<Button onClick={() => setCreateOpen(true)}>新建环境</Button>}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table columns={ENV_COLUMNS} rows={rows} />
|
<Table columns={ENV_COLUMNS} rows={rows} />
|
||||||
@@ -293,8 +294,8 @@ export function PythonEnvsSection() {
|
|||||||
label="Python 版本(可选)"
|
label="Python 版本(可选)"
|
||||||
hint={
|
hint={
|
||||||
(pythonsQuery.data?.length ?? 0) === 0
|
(pythonsQuery.data?.length ?? 0) === 0
|
||||||
? '还没有下载 Python;先在上方「Python 版本」里下载,或选默认让 uv 自动下载'
|
? '还没有下载解释器:先在「uv 配置 → Python 解释器」里下载,或选默认让 uv 自动决定'
|
||||||
: '列表来自 uv 下载到平台目录的版本;留空由 uv 决定'
|
: '列表来自 uv 下载到程序目录的版本(在「uv 配置」里下载);留空由 uv 决定'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Text,
|
Text,
|
||||||
TextButton,
|
TextButton,
|
||||||
Title,
|
|
||||||
toast,
|
toast,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
@@ -105,7 +104,6 @@ export function PythonMirrorsSection() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mirrors = mirrorsQuery.data ?? []
|
const mirrors = mirrorsQuery.data ?? []
|
||||||
const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null
|
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingMirror(null)
|
setEditingMirror(null)
|
||||||
@@ -168,7 +166,7 @@ export function PythonMirrorsSection() {
|
|||||||
<>
|
<>
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<Space justify="between" align="center" wrap>
|
<Space justify="between" align="center" wrap>
|
||||||
<Space direction="column" gap="xs">
|
<Space direction="column" gap="xs" align="start">
|
||||||
<Text strong>包索引镜像</Text>
|
<Text strong>包索引镜像</Text>
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
|
默认镜像在 Python 脚本任务运行时注入 PIP_INDEX_URL / UV_INDEX_URL。
|
||||||
@@ -178,30 +176,6 @@ export function PythonMirrorsSection() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</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="镜像只影响任务运行,不会自动安装依赖">
|
<Notice title="镜像只影响任务运行,不会自动安装依赖">
|
||||||
默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL
|
默认镜像在 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,6 @@ const TRIGGER_FILTERS = [
|
|||||||
{ value: '', label: '全部触发' },
|
{ value: '', label: '全部触发' },
|
||||||
{ value: 'schedule', label: '定时' },
|
{ value: 'schedule', label: '定时' },
|
||||||
{ value: 'manual', label: '手动' },
|
{ value: 'manual', label: '手动' },
|
||||||
{ value: 'retry', label: '重试' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,14 +30,20 @@ export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
|||||||
|
|
||||||
interface EnabledBadgeProps {
|
interface EnabledBadgeProps {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
|
export function EnabledBadge({ enabled, className }: EnabledBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
tone={enabled ? 'success' : 'neutral'}
|
tone={enabled ? 'success' : 'neutral'}
|
||||||
dot
|
dot
|
||||||
className={enabled ? 'status-enabled' : 'status-disabled'}
|
className={[
|
||||||
|
enabled ? 'status-enabled' : 'status-disabled',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
>
|
>
|
||||||
{enabled ? '活跃中' : '已暂停'}
|
{enabled ? '活跃中' : '已暂停'}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -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>
|
</FormField>
|
||||||
</div>
|
</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">
|
<div className="form-grid">
|
||||||
<FormField
|
<FormField
|
||||||
label="Cron 表达式"
|
label="Cron 表达式"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -84,6 +84,7 @@ export function detectScriptLanguage(interpreter: string): ScriptLanguage {
|
|||||||
export interface TaskFormValues {
|
export interface TaskFormValues {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
description: string
|
||||||
cron: string
|
cron: string
|
||||||
timezone: string
|
timezone: string
|
||||||
timeout_secs: number
|
timeout_secs: number
|
||||||
@@ -142,6 +143,10 @@ export const taskFormSchema = z
|
|||||||
.trim()
|
.trim()
|
||||||
.min(1, '请输入任务名称')
|
.min(1, '请输入任务名称')
|
||||||
.max(120, '任务名称不能超过 120 个字符'),
|
.max(120, '任务名称不能超过 120 个字符'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(500, '任务说明不能超过 500 个字符'),
|
||||||
cron: z
|
cron: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -227,6 +232,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
|||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
description: '',
|
||||||
cron: '0 0 2 * * *',
|
cron: '0 0 2 * * *',
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
timeout_secs: 300,
|
timeout_secs: 300,
|
||||||
@@ -261,6 +267,7 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
|||||||
const values = createEmptyTaskFormValues()
|
const values = createEmptyTaskFormValues()
|
||||||
values.id = task.id
|
values.id = task.id
|
||||||
values.name = task.name
|
values.name = task.name
|
||||||
|
values.description = task.description ?? ''
|
||||||
values.cron = task.cron
|
values.cron = task.cron
|
||||||
values.timezone = task.timezone
|
values.timezone = task.timezone
|
||||||
values.timeout_secs = task.timeout_secs
|
values.timeout_secs = task.timeout_secs
|
||||||
@@ -359,15 +366,11 @@ export function valuesToUpdateRequest(
|
|||||||
): UpdateTaskRequest {
|
): UpdateTaskRequest {
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
|
description: values.description.trim(),
|
||||||
cron: values.cron.trim(),
|
cron: values.cron.trim(),
|
||||||
timezone: values.timezone.trim(),
|
timezone: values.timezone.trim(),
|
||||||
timeout_secs: values.timeout_secs,
|
timeout_secs: values.timeout_secs,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
concurrency_policy: 'skip',
|
|
||||||
retry_policy: {
|
|
||||||
max_attempts: 0,
|
|
||||||
delay_secs: 0,
|
|
||||||
},
|
|
||||||
action: valuesToAction(values),
|
action: valuesToAction(values),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
Modal,
|
Modal,
|
||||||
Notice,
|
Notice,
|
||||||
Pagination,
|
|
||||||
Result,
|
Result,
|
||||||
SearchField,
|
SearchField,
|
||||||
Select,
|
Select,
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
fetchStats,
|
fetchStats,
|
||||||
getUvSettings,
|
getUvSettings,
|
||||||
listManagedEnvs,
|
listManagedEnvs,
|
||||||
|
listPythons,
|
||||||
listPythonMirrors,
|
listPythonMirrors,
|
||||||
listTasks,
|
listTasks,
|
||||||
runTask,
|
runTask,
|
||||||
@@ -45,11 +45,11 @@ import {
|
|||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import { PythonEnvsSection } from '../components/PythonEnvsSection'
|
import { PythonEnvsSection } from '../components/PythonEnvsSection'
|
||||||
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
|
import { PythonMirrorsSection } from '../components/PythonMirrorsSection'
|
||||||
import { PythonRuntimeSection } from '../components/PythonRuntimeSection'
|
|
||||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||||
import { EnabledBadge } from '../components/StatusBadge'
|
import { EnabledBadge } from '../components/StatusBadge'
|
||||||
|
import { TaskDetailModal } from '../components/TaskDetailModal'
|
||||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||||
import { UvSettingsSection } from '../components/UvSettingsSection'
|
import { UvSettingsModal } from '../components/UvSettingsModal'
|
||||||
import {
|
import {
|
||||||
actionTypeLabel,
|
actionTypeLabel,
|
||||||
formatShortDateTime,
|
formatShortDateTime,
|
||||||
@@ -62,7 +62,7 @@ import {
|
|||||||
valuesToUpdateRequest,
|
valuesToUpdateRequest,
|
||||||
} from '../lib/taskForm'
|
} from '../lib/taskForm'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const MAX_TASKS = 100
|
||||||
|
|
||||||
const ENABLED_OPTIONS = [
|
const ENABLED_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: '', label: '全部状态' },
|
||||||
@@ -77,12 +77,12 @@ const ACTION_TYPE_OPTIONS = [
|
|||||||
{ value: 'process', label: '进程' },
|
{ value: 'process', label: '进程' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/// 中间栏按任务类型分组,和顶部筛选配合使用。
|
/// 列表按任务类型排在一起,类型在卡片里用标签标出。
|
||||||
const TASK_GROUPS: Array<{ type: TaskActionType; label: string }> = [
|
const TYPE_ORDER: Record<TaskActionType, number> = {
|
||||||
{ type: 'script', label: '脚本' },
|
script: 0,
|
||||||
{ type: 'http', label: 'HTTP 请求' },
|
http: 1,
|
||||||
{ type: 'process', label: '二进制程序' },
|
process: 2,
|
||||||
]
|
}
|
||||||
|
|
||||||
export function TaskDashboard() {
|
export function TaskDashboard() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -90,14 +90,13 @@ export function TaskDashboard() {
|
|||||||
const q = searchParams.get('q') ?? ''
|
const q = searchParams.get('q') ?? ''
|
||||||
const enabledValue = searchParams.get('enabled')
|
const enabledValue = searchParams.get('enabled')
|
||||||
const actionTypeValue = searchParams.get('action_type')
|
const actionTypeValue = searchParams.get('action_type')
|
||||||
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
|
||||||
const [logTask, setLogTask] = 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 [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
|
||||||
const [uvSettingsOpen, setUvSettingsOpen] = useState(false)
|
const [uvSettingsOpen, setUvSettingsOpen] = useState(false)
|
||||||
const [mirrorSettingsOpen, setMirrorSettingsOpen] = useState(false)
|
const [mirrorSettingsOpen, setMirrorSettingsOpen] = useState(false)
|
||||||
const [envManagerOpen, setEnvManagerOpen] = useState(false)
|
const [envManagerOpen, setEnvManagerOpen] = useState(false)
|
||||||
@@ -118,8 +117,8 @@ export function TaskDashboard() {
|
|||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
enabled,
|
enabled,
|
||||||
action_type: actionType,
|
action_type: actionType,
|
||||||
page,
|
page: 1,
|
||||||
page_size: PAGE_SIZE,
|
page_size: MAX_TASKS,
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthQuery = useQuery({
|
const healthQuery = useQuery({
|
||||||
@@ -154,6 +153,11 @@ export function TaskDashboard() {
|
|||||||
queryKey: ['managed-envs'],
|
queryKey: ['managed-envs'],
|
||||||
queryFn: ({ signal }) => listManagedEnvs(signal),
|
queryFn: ({ signal }) => listManagedEnvs(signal),
|
||||||
})
|
})
|
||||||
|
const pythonsQuery = useQuery({
|
||||||
|
queryKey: ['managed-pythons'],
|
||||||
|
queryFn: ({ signal }) => listPythons(signal),
|
||||||
|
enabled: Boolean(uvQuery.data?.version),
|
||||||
|
})
|
||||||
|
|
||||||
const nextRuns = new Map(
|
const nextRuns = new Map(
|
||||||
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
(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
|
mirrorsQuery.data?.find((mirror) => mirror.is_default) ?? null
|
||||||
const uv = uvQuery.data
|
const uv = uvQuery.data
|
||||||
const managedEnvs = envsQuery.data ?? []
|
const managedEnvs = envsQuery.data ?? []
|
||||||
|
const pythonCount = pythonsQuery.data?.length ?? 0
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
@@ -227,19 +232,25 @@ export function TaskDashboard() {
|
|||||||
if (nextQuery === q) {
|
if (nextQuery === q) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateFilters({ q: nextQuery || undefined, page: '1' })
|
updateFilters({ q: nextQuery || undefined })
|
||||||
// updateFilters 依赖当前 searchParams,每次渲染都是新的闭包;这里只跟随防抖后的输入。
|
// updateFilters 依赖当前 searchParams,每次渲染都是新的闭包;这里只跟随防抖后的输入。
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedQuery])
|
}, [debouncedQuery])
|
||||||
|
|
||||||
const tasks = tasksQuery.data?.items ?? []
|
const tasks = tasksQuery.data?.items ?? []
|
||||||
const total = tasksQuery.data?.total ?? 0
|
const sortedTasks = [...tasks].sort(
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
(left, right) =>
|
||||||
const groups = TASK_GROUPS.map((group) => ({
|
TYPE_ORDER[left.action.type] - TYPE_ORDER[right.action.type],
|
||||||
...group,
|
)
|
||||||
tasks: tasks.filter((task) => task.action.type === group.type),
|
// 最近一次触发:取所有已注册调度里最早的 next_run_at,连带任务名一起显示
|
||||||
})).filter((group) => group.tasks.length > 0)
|
const nearest = (scheduleQuery.data ?? [])
|
||||||
const nearestRun = [...nextRuns.values()].sort()[0]
|
.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>) {
|
function updateFilters(changes: Record<string, string | undefined>) {
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
@@ -263,21 +274,6 @@ export function TaskDashboard() {
|
|||||||
setEditorOpen(true)
|
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 (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
<div className="dashboard-head">
|
<div className="dashboard-head">
|
||||||
@@ -295,27 +291,43 @@ export function TaskDashboard() {
|
|||||||
{/* 左栏:用户 / 概览 / 调度 */}
|
{/* 左栏:用户 / 概览 / 调度 */}
|
||||||
<aside className="dashboard-column dashboard-left" aria-label="概览">
|
<aside className="dashboard-column dashboard-left" aria-label="概览">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="user-card">
|
<div className="user-profile">
|
||||||
<div className="user-avatar">
|
<Avatar
|
||||||
<Avatar fallback="本" size="lg" tone="primary" verified />
|
className="user-profile-avatar"
|
||||||
<span className="user-online" />
|
fallback="本"
|
||||||
</div>
|
size="xl"
|
||||||
<div className="user-meta">
|
tone="primary"
|
||||||
<Space gap="xs" align="center" wrap>
|
verified
|
||||||
<Text strong>本地用户</Text>
|
/>
|
||||||
<Tag tone="success" shape="rounded">
|
<div className="user-profile-name-row">
|
||||||
单机
|
<Text size="lg" strong>
|
||||||
</Tag>
|
本地用户
|
||||||
</Space>
|
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
永远本地模式
|
|
||||||
<span className="user-dot" />
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<span className="user-profile-status" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
<Tag shape="pill" className="user-profile-role">
|
||||||
|
管理员
|
||||||
|
</Tag>
|
||||||
|
<p className="user-profile-bio">
|
||||||
|
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
|
||||||
|
</p>
|
||||||
|
<Text size="xs" tone="muted" className="user-profile-meta">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
healthQuery.isError
|
||||||
|
? 'user-profile-engine-dot is-offline'
|
||||||
|
: 'user-profile-engine-dot'
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
引擎
|
||||||
|
{healthQuery.isPending
|
||||||
|
? '检测中'
|
||||||
|
: healthQuery.isError
|
||||||
|
? '离线'
|
||||||
|
: '在线'}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<p className="user-bio">
|
|
||||||
任务、日志和 Python 环境都保存在本机;不依赖云服务,也不会把数据发出去。
|
|
||||||
</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
@@ -339,42 +351,36 @@ export function TaskDashboard() {
|
|||||||
loading={statsQuery.isPending}
|
loading={statsQuery.isPending}
|
||||||
/>
|
/>
|
||||||
<DashMetric
|
<DashMetric
|
||||||
label="Rust 引擎"
|
label="HTTP 请求"
|
||||||
value={
|
value={statsQuery.data?.http}
|
||||||
healthQuery.isPending
|
loading={statsQuery.isPending}
|
||||||
? '...'
|
/>
|
||||||
: healthQuery.isError
|
<DashMetric
|
||||||
? '离线'
|
label="二进制程序"
|
||||||
: '在线'
|
value={statsQuery.data?.process}
|
||||||
}
|
loading={statsQuery.isPending}
|
||||||
loading={false}
|
|
||||||
danger={healthQuery.isError}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<Text strong size="sm">
|
<Text strong size="sm">
|
||||||
调度
|
最近触发
|
||||||
</Text>
|
</Text>
|
||||||
<div className="dash-kv">
|
{nearest ? (
|
||||||
<Text size="xs" tone="muted">
|
<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>
|
</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) : '暂无'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -388,7 +394,7 @@ export function TaskDashboard() {
|
|||||||
onChange={setQueryDraft}
|
onChange={setQueryDraft}
|
||||||
placeholder="搜索任务名称或 ID"
|
placeholder="搜索任务名称或 ID"
|
||||||
size="md"
|
size="md"
|
||||||
maxWidth={360}
|
maxWidth={420}
|
||||||
/>
|
/>
|
||||||
<Space gap="xs" wrap align="center">
|
<Space gap="xs" wrap align="center">
|
||||||
<Select
|
<Select
|
||||||
@@ -398,7 +404,6 @@ export function TaskDashboard() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateFilters({
|
updateFilters({
|
||||||
enabled: value || undefined,
|
enabled: value || undefined,
|
||||||
page: '1',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -409,21 +414,9 @@ export function TaskDashboard() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateFilters({
|
updateFilters({
|
||||||
action_type: value || undefined,
|
action_type: value || undefined,
|
||||||
page: '1',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{hasFilters ? (
|
|
||||||
<TextButton onClick={() => setSearchParams({})}>
|
|
||||||
清除筛选
|
|
||||||
</TextButton>
|
|
||||||
) : null}
|
|
||||||
<TextButton
|
|
||||||
onClick={() => void refreshAll()}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</TextButton>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -486,58 +479,29 @@ export function TaskDashboard() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
sortedTasks.map((task) => (
|
||||||
{groups.map((group) => (
|
<TaskCard
|
||||||
<Card key={group.type} padding="md">
|
key={task.id}
|
||||||
<Space justify="between" align="center">
|
task={task}
|
||||||
<Space gap="xs" align="center">
|
nextRunAt={nextRuns.get(task.id)}
|
||||||
<Text strong>{group.label}</Text>
|
running={
|
||||||
<Tag shape="rounded">{group.tasks.length}</Tag>
|
runMutation.isPending && runMutation.variables?.id === task.id
|
||||||
</Space>
|
}
|
||||||
<Text size="xs" tone="muted">
|
toggling={
|
||||||
{actionTypeLabel(group.type)}
|
toggleMutation.isPending &&
|
||||||
</Text>
|
toggleMutation.variables?.id === task.id
|
||||||
</Space>
|
}
|
||||||
<div className="task-grid">
|
onRun={() => runMutation.mutate(task)}
|
||||||
{group.tasks.map((task) => (
|
onLogs={() => {
|
||||||
<TaskItem
|
setLogTask(task)
|
||||||
key={task.id}
|
setInitialRunId(null)
|
||||||
task={task}
|
}}
|
||||||
nextRunAt={nextRuns.get(task.id)}
|
onEdit={() => openEdit(task)}
|
||||||
running={
|
onToggle={() => toggleMutation.mutate(task)}
|
||||||
runMutation.isPending &&
|
onDelete={() => setDeleteTarget(task)}
|
||||||
runMutation.variables?.id === task.id
|
onDetails={() => setDetailTask(task)}
|
||||||
}
|
/>
|
||||||
toggling={
|
))
|
||||||
toggleMutation.isPending &&
|
|
||||||
toggleMutation.variables?.id === task.id
|
|
||||||
}
|
|
||||||
onRun={() => runMutation.mutate(task)}
|
|
||||||
onLogs={() => {
|
|
||||||
setLogTask(task)
|
|
||||||
setInitialRunId(null)
|
|
||||||
}}
|
|
||||||
onEdit={() => openEdit(task)}
|
|
||||||
onToggle={() => toggleMutation.mutate(task)}
|
|
||||||
onDelete={() => setDeleteTarget(task)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
@@ -548,17 +512,33 @@ export function TaskDashboard() {
|
|||||||
<Text strong size="sm">
|
<Text strong size="sm">
|
||||||
uv
|
uv
|
||||||
</Text>
|
</Text>
|
||||||
<TextButton onClick={() => setUvSettingsOpen(true)}>
|
<TextButton label="配置 uv" onClick={() => setUvSettingsOpen(true)}>
|
||||||
配置
|
配置
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</Space>
|
</Space>
|
||||||
<div className="dash-kv">
|
<div className="dash-inline-kv">
|
||||||
<Text size="xs" tone="muted">
|
<span className="dash-inline-item">
|
||||||
版本
|
<Text size="xs" tone="muted">
|
||||||
</Text>
|
版本
|
||||||
<Text className="mono" size="sm">
|
</Text>
|
||||||
{shortUvVersion(uv?.version) ?? '未检测到'}
|
<Text className="mono" size="sm">
|
||||||
</Text>
|
{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>
|
||||||
<div className="dash-kv">
|
<div className="dash-kv">
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
@@ -576,6 +556,19 @@ export function TaskDashboard() {
|
|||||||
{uv?.python_dir ?? 'python'}
|
{uv?.python_dir ?? 'python'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
@@ -583,17 +576,38 @@ export function TaskDashboard() {
|
|||||||
<Text strong size="sm">
|
<Text strong size="sm">
|
||||||
镜像源
|
镜像源
|
||||||
</Text>
|
</Text>
|
||||||
<TextButton onClick={() => setMirrorSettingsOpen(true)}>
|
<TextButton
|
||||||
|
label="配置镜像源"
|
||||||
|
onClick={() => setMirrorSettingsOpen(true)}
|
||||||
|
>
|
||||||
配置
|
配置
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</Space>
|
</Space>
|
||||||
<div className="dash-kv">
|
<div className="mirror-card-summary">
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
当前默认镜像
|
当前默认镜像
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mono" size="xs" tone="sub">
|
<Text strong size="sm" truncate>
|
||||||
{defaultMirror?.index_url ?? '未设置'}
|
{defaultMirror?.name ?? '未设置默认镜像'}
|
||||||
</Text>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -627,16 +641,12 @@ export function TaskDashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 配置弹窗:uv 与镜像源分开,各自一个弹窗;关闭时不渲染内容,避免重复表单 */}
|
{/* 配置弹窗:uv(含解释器下载)与镜像源分开,各自一个弹窗 */}
|
||||||
{uvSettingsOpen ? (
|
{uvSettingsOpen ? (
|
||||||
<Modal
|
<UvSettingsModal
|
||||||
open
|
open
|
||||||
onClose={() => setUvSettingsOpen(false)}
|
onClose={() => setUvSettingsOpen(false)}
|
||||||
title="uv 配置"
|
/>
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<UvSettingsSection onClose={() => setUvSettingsOpen(false)} />
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mirrorSettingsOpen ? (
|
{mirrorSettingsOpen ? (
|
||||||
@@ -645,6 +655,7 @@ export function TaskDashboard() {
|
|||||||
onClose={() => setMirrorSettingsOpen(false)}
|
onClose={() => setMirrorSettingsOpen(false)}
|
||||||
title="镜像源配置"
|
title="镜像源配置"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
className="mirror-settings-modal"
|
||||||
footer={
|
footer={
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -673,10 +684,7 @@ export function TaskDashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="modal-stack">
|
<PythonEnvsSection />
|
||||||
<PythonRuntimeSection />
|
|
||||||
<PythonEnvsSection />
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -694,6 +702,12 @@ export function TaskDashboard() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TaskDetailModal
|
||||||
|
task={detailTask}
|
||||||
|
nextRunAt={detailTask ? nextRuns.get(detailTask.id) : undefined}
|
||||||
|
onClose={() => setDetailTask(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<RunLogDrawer
|
<RunLogDrawer
|
||||||
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
|
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
|
||||||
open={Boolean(logTask)}
|
open={Boolean(logTask)}
|
||||||
@@ -729,16 +743,15 @@ interface DashMetricProps {
|
|||||||
label: string
|
label: string
|
||||||
value: number | string | undefined
|
value: number | string | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
danger?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashMetric({ label, value, loading, danger = false }: DashMetricProps) {
|
function DashMetric({ label, value, loading }: DashMetricProps) {
|
||||||
return (
|
return (
|
||||||
<div className="dash-metric">
|
<div className="dash-metric">
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<span className={danger ? 'dash-metric-value dash-metric-danger' : 'dash-metric-value'}>
|
<span className="dash-metric-value">
|
||||||
{loading ? '...' : (value ?? '--')}
|
{loading ? '...' : (value ?? '--')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,9 +768,10 @@ interface TaskItemProps {
|
|||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onDetails: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskItem({
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
nextRunAt,
|
nextRunAt,
|
||||||
running,
|
running,
|
||||||
@@ -767,51 +781,82 @@ function TaskItem({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDetails,
|
||||||
}: TaskItemProps) {
|
}: TaskItemProps) {
|
||||||
const mark = task.name.trim().charAt(0).toUpperCase() || 'T'
|
|
||||||
return (
|
return (
|
||||||
<div className="task-item">
|
<Card padding="sm">
|
||||||
<div className="task-item-main">
|
<div className="task-card-head">
|
||||||
<span className={`task-item-mark task-item-mark-${task.action.type}`}>
|
<EnabledBadge
|
||||||
{mark}
|
enabled={task.enabled}
|
||||||
</span>
|
className="task-card-status-badge"
|
||||||
<div className="task-item-text">
|
/>
|
||||||
|
<TextButton className="task-card-detail" onClick={onDetails}>
|
||||||
|
详情
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
<div className="task-card">
|
||||||
|
<div className="task-card-main">
|
||||||
<Space gap="xs" align="center" wrap>
|
<Space gap="xs" align="center" wrap>
|
||||||
<Text strong>{task.name}</Text>
|
<Text className="task-card-title" size="lg" strong truncate>
|
||||||
<EnabledBadge enabled={task.enabled} />
|
{task.name}
|
||||||
</Space>
|
|
||||||
<Text className="task-id" size="xs" tone="muted">
|
|
||||||
{task.id}
|
|
||||||
</Text>
|
|
||||||
<Space gap="xs" align="center" wrap>
|
|
||||||
<code className="code-chip">{task.cron}</code>
|
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
{task.enabled
|
|
||||||
? nextRunAt
|
|
||||||
? `下次 ${formatShortDateTime(nextRunAt)}`
|
|
||||||
: '调度中…'
|
|
||||||
: '未调度'}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Text
|
||||||
|
className="task-card-desc"
|
||||||
|
size="xs"
|
||||||
|
tone="muted"
|
||||||
|
truncate
|
||||||
|
title={task.description.trim() || taskTarget(task)}
|
||||||
|
>
|
||||||
|
{task.description.trim() || taskTarget(task)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-card-plan">
|
||||||
|
<code className="code-chip">{task.cron}</code>
|
||||||
|
<span className="task-card-next">
|
||||||
|
{task.enabled
|
||||||
|
? nextRunAt
|
||||||
|
? `下次 ${formatShortDateTime(nextRunAt)}`
|
||||||
|
: '调度中…'
|
||||||
|
: '未调度'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-card-side">
|
||||||
|
<Space className="task-card-actions" gap="xs" wrap justify="end">
|
||||||
|
<TextButton disabled={running} title="立即运行" onClick={onRun}>
|
||||||
|
{running ? '提交中' : '运行'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton onClick={onLogs}>日志</TextButton>
|
||||||
|
<TextButton onClick={onEdit}>编辑</TextButton>
|
||||||
|
<TextButton disabled={toggling} onClick={onToggle}>
|
||||||
|
{task.enabled ? '停用' : '启用'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton tone="danger" onClick={onDelete}>
|
||||||
|
删除
|
||||||
|
</TextButton>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space className="task-item-actions" gap="xs" wrap justify="end">
|
</Card>
|
||||||
<TextButton disabled={running} title="立即运行" onClick={onRun}>
|
|
||||||
{running ? '提交中' : '运行'}
|
|
||||||
</TextButton>
|
|
||||||
<TextButton onClick={onLogs}>日志</TextButton>
|
|
||||||
<TextButton onClick={onEdit}>编辑</TextButton>
|
|
||||||
<TextButton disabled={toggling} onClick={onToggle}>
|
|
||||||
{task.enabled ? '停用' : '启用'}
|
|
||||||
</TextButton>
|
|
||||||
<TextButton tone="danger" onClick={onDelete}>
|
|
||||||
删除
|
|
||||||
</TextButton>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 卡片第二行的一句话说明:任务标识 + 它实际执行什么。 */
|
||||||
|
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 {
|
function isActionType(value: string | null): value is TaskActionType {
|
||||||
return value === 'script' || value === 'http' || value === 'process'
|
return value === 'script' || value === 'http' || value === 'process'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user