From 29fc579275d6a4c7b73938b6a255829e1ac610c9 Mon Sep 17 00:00:00 2001 From: CNWei Date: Tue, 3 Nov 2026 22:12:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A6=E4=BE=A7=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=94=B9=E7=89=88=E4=B8=8E=20Python=20=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开 --- README.md | 36 +- .../migrations/0003_create_python_mirrors.sql | 62 +++ backend/src/api.rs | 18 + backend/src/error.rs | 20 + backend/src/lib.rs | 1 + backend/src/python_env.rs | 2 +- backend/src/python_mirrors/mod.rs | 4 + backend/src/python_mirrors/model.rs | 52 +++ backend/src/python_mirrors/repository.rs | 215 +++++++++++ backend/src/python_mirrors/routes.rs | 151 ++++++++ backend/src/python_mirrors/service.rs | 79 ++++ backend/src/runs/executor.rs | 54 ++- backend/tests/mirrors.rs | 243 ++++++++++++ contracts/openapi.json | 352 ++++++++++++++++++ frontend/e2e/dashboard.spec.ts | 48 ++- frontend/src/App.css | 187 +++++++++- frontend/src/App.test.tsx | 31 ++ frontend/src/App.tsx | 27 +- frontend/src/api/client.ts | 44 +++ frontend/src/api/generated.ts | 286 ++++++++++++++ frontend/src/components/AppSidebar.tsx | 72 ++++ frontend/src/components/PageHeader.tsx | 32 ++ frontend/src/components/PythonMirrorModal.tsx | 214 +++++++++++ frontend/src/pages/PythonMirrorsPage.tsx | 275 ++++++++++++++ frontend/src/pages/TaskDashboard.tsx | 33 +- 25 files changed, 2491 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/0003_create_python_mirrors.sql create mode 100644 backend/src/python_mirrors/mod.rs create mode 100644 backend/src/python_mirrors/model.rs create mode 100644 backend/src/python_mirrors/repository.rs create mode 100644 backend/src/python_mirrors/routes.rs create mode 100644 backend/src/python_mirrors/service.rs create mode 100644 backend/tests/mirrors.rs create mode 100644 frontend/src/components/AppSidebar.tsx create mode 100644 frontend/src/components/PageHeader.tsx create mode 100644 frontend/src/components/PythonMirrorModal.tsx create mode 100644 frontend/src/pages/PythonMirrorsPage.tsx diff --git a/README.md b/README.md index 5396363..1fd7047 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - SQLite 持久化,重启后任务与历史仍在 - 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示 +- Python 包镜像管理:内置常用 PyPI 源,默认镜像在任务运行时注入 `PIP_INDEX_URL` / `UV_INDEX_URL` - 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体 > 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。 @@ -116,8 +117,8 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000 | 进程治理 | Windows Job Object 管理进程树;超时或取消时终止整棵树 | | 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 | | 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 | -| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 | -| 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 | +| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验、默认 PyPI 镜像注入 | +| 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 | | 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 | | 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 | @@ -229,14 +230,19 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled ## Web 界面 +- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。 - **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。 -- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。 -- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。 +- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。 +- **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览; + 探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 - **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 +- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。 + 默认镜像在 Python 脚本任务启动时注入 `PIP_INDEX_URL` / `UV_INDEX_URL` + (任务自身配置的环境变量优先),不会自动安装依赖。 - **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自 `@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与 - 业务块(任务表单元格、日志控制台、表单栅格)。 + 业务块(侧栏、任务表单元格、日志控制台、表单栅格)。 - **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用 `LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用 `tools/build-fonts.py` 生成,缺失时回退系统字体。 @@ -292,6 +298,17 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled | `GET` | `/api/v1/stats` | 仪表盘计数(总数/启用/运行中/三类数量) | | `GET` | `/api/v1/schedule` | 已启用任务的下次触发时间 | +### Python 镜像 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/v1/python/mirrors` | 镜像列表(默认项优先) | +| `POST` | `/api/v1/python/mirrors` | 新增镜像 | +| `GET` | `/api/v1/python/mirrors/{id}` | 镜像详情 | +| `PUT` | `/api/v1/python/mirrors/{id}` | 更新名称 / 地址 / 信任主机 / 启用状态 | +| `DELETE` | `/api/v1/python/mirrors/{id}` | 删除镜像 | +| `POST` | `/api/v1/python/mirrors/{id}/default` | 设为默认(同一时间仅一个默认,并自动启用) | + ### 任务与运行 | 方法 | 路径 | 说明 | @@ -425,6 +442,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | `executor.rs` | 三类 Action 执行、并发闸门、超时/取消、日志脱敏与截断 | | `process_job.rs` | Windows Job Object(`KILL_ON_JOB_CLOSE`) | | `python_env.rs` | 虚拟环境解析与探测、PATH/编码注入 | +| `python_mirrors/` | PyPI 镜像的模型、校验、CRUD 与默认项事务 | | `directory_browser.rs` | 盘符与目录列举(页面内目录选择器) | | `security.rs` | Origin 白名单、令牌校验、常量时间比较 | | `database.rs` | 连接池与迁移(WAL/busy_timeout) | @@ -434,7 +452,11 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin + | 模块 | 职责 | | --- | --- | | `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 | +| `components/AppSidebar.tsx` | 左侧导航、品牌、引擎状态 | +| `components/PageHeader.tsx` | 页面标题区(标题 / 说明 / 操作) | | `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 | +| `pages/PythonMirrorsPage.tsx` | Python 镜像列表、默认项、新增 / 编辑 / 删除 | +| `components/PythonMirrorModal.tsx` | 镜像表单(名称 / 索引地址 / 信任主机 / 启用) | | `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 | | `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 | | `components/RunLogDrawer.tsx` | 运行历史、日志、取消、下载、SSE | @@ -504,8 +526,8 @@ uv run --with fonttools --with brotli tools/build-fonts.py | --- | --- | | Rust 单元(57) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 | | Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 | -| 前端 Vitest(28) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | -| Playwright(5) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动 | +| 前端 Vitest(29) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 | +| Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 | | Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 | --- diff --git a/backend/migrations/0003_create_python_mirrors.sql b/backend/migrations/0003_create_python_mirrors.sql new file mode 100644 index 0000000..f0e0133 --- /dev/null +++ b/backend/migrations/0003_create_python_mirrors.sql @@ -0,0 +1,62 @@ +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') + ); diff --git a/backend/src/api.rs b/backend/src/api.rs index 75f1ff6..519621b 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -20,6 +20,14 @@ use crate::{ python_env::{ self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource, }, + python_mirrors::{ + model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest}, + routes::{ + __path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror, + __path_list_python_mirrors, __path_set_default_python_mirror, + __path_update_python_mirror, + }, + }, runs::{ model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse}, routes::{ @@ -219,6 +227,12 @@ pub async fn list_schedule( session, preview_cron, probe_python_venv, + list_python_mirrors, + get_python_mirror, + create_python_mirror, + update_python_mirror, + delete_python_mirror, + set_default_python_mirror, list_directories, task_stats, list_schedule, @@ -247,6 +261,9 @@ pub async fn list_schedule( VenvProbeStatus, VenvSource, InterpreterKind, + PythonMirrorResponse, + CreatePythonMirrorRequest, + UpdatePythonMirrorRequest, DirectoryEntry, DirectoryListingResponse, TaskStatsResponse, @@ -305,6 +322,7 @@ pub fn router(state: AppState) -> Router { .route("/api/v1/schedule", get(list_schedule)) .route("/api/v1/openapi.json", get(openapi_json)) .merge(crate::tasks::routes::router()) + .merge(crate::python_mirrors::routes::router()) .merge(crate::runs::routes::router()) .route("/api/{*path}", any(api_not_found)) // 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。 diff --git a/backend/src/error.rs b/backend/src/error.rs index 37ebfa2..796c370 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -109,6 +109,26 @@ impl ApiError { } } + pub fn mirror_not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "MIRROR_NOT_FOUND", + message: message.into(), + field: None, + details: BTreeMap::new(), + } + } + + pub fn mirror_conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + code: "MIRROR_CONFLICT", + message: message.into(), + field: Some("index_url".to_owned()), + details: BTreeMap::new(), + } + } + pub fn route_not_found(message: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 73fe15e..b3097ac 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,6 +5,7 @@ pub mod directory_browser; pub mod error; pub mod process_job; pub mod python_env; +pub mod python_mirrors; pub mod runs; pub mod scheduler; pub mod security; diff --git a/backend/src/python_env.rs b/backend/src/python_env.rs index c9a2617..059c798 100644 --- a/backend/src/python_env.rs +++ b/backend/src/python_env.rs @@ -472,7 +472,7 @@ fn absolute_path(path: &Path) -> io::Result { std::path::absolute(path) } -fn is_python_interpreter(interpreter: &str) -> bool { +pub fn is_python_interpreter(interpreter: &str) -> bool { let name = Path::new(interpreter) .file_name() .and_then(|name| name.to_str()) diff --git a/backend/src/python_mirrors/mod.rs b/backend/src/python_mirrors/mod.rs new file mode 100644 index 0000000..f019754 --- /dev/null +++ b/backend/src/python_mirrors/mod.rs @@ -0,0 +1,4 @@ +pub mod model; +pub mod repository; +pub mod routes; +pub mod service; diff --git a/backend/src/python_mirrors/model.rs b/backend/src/python_mirrors/model.rs new file mode 100644 index 0000000..fce1c9f --- /dev/null +++ b/backend/src/python_mirrors/model.rs @@ -0,0 +1,52 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// 一个 Python 包索引镜像(pip / uv)。 +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PythonMirrorResponse { + pub id: String, + pub name: String, + /// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL` + pub index_url: String, + /// pip 的 `--trusted-host`;https 源通常为空 + pub trusted_host: Option, + pub enabled: bool, + /// 当前默认镜像;同一时间最多一个 + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct CreatePythonMirrorRequest { + pub name: String, + pub index_url: String, + #[serde(default)] + pub trusted_host: Option, + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdatePythonMirrorRequest { + pub name: String, + pub index_url: String, + #[serde(default)] + pub trusted_host: Option, + pub enabled: bool, +} + +const fn default_enabled() -> bool { + true +} + +/// 执行器每次运行 Python 任务时读取的默认镜像。 +#[derive(Debug, Clone)] +pub struct DefaultPythonMirror { + pub name: String, + pub index_url: String, + pub trusted_host: Option, +} diff --git a/backend/src/python_mirrors/repository.rs b/backend/src/python_mirrors/repository.rs new file mode 100644 index 0000000..b4ab952 --- /dev/null +++ b/backend/src/python_mirrors/repository.rs @@ -0,0 +1,215 @@ +use chrono::{DateTime, Utc}; +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use crate::{ + error::{ApiError, ApiResult}, + python_mirrors::{ + model::{DefaultPythonMirror, PythonMirrorResponse}, + service::ValidatedMirror, + }, +}; + +#[derive(Debug, FromRow)] +struct MirrorRow { + id: String, + name: String, + index_url: String, + trusted_host: Option, + enabled: bool, + is_default: bool, + created_at: DateTime, + updated_at: DateTime, +} + +impl From for PythonMirrorResponse { + fn from(row: MirrorRow) -> Self { + Self { + id: row.id, + name: row.name, + index_url: row.index_url, + trusted_host: row.trusted_host, + enabled: row.enabled, + is_default: row.is_default, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +pub async fn list(pool: &SqlitePool) -> ApiResult> { + let rows = sqlx::query_as::<_, MirrorRow>( + "SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at + FROM python_mirrors + ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC", + ) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) +} + +pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult { + let row = sqlx::query_as::<_, MirrorRow>( + "SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at + FROM python_mirrors WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?; + Ok(row.into()) +} + +pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult { + if index_url_exists(pool, &input.index_url, None).await? { + return Err(ApiError::mirror_conflict("该索引地址已经存在")); + } + + let id = Uuid::new_v4().simple().to_string(); + let now = Utc::now(); + sqlx::query( + "INSERT INTO python_mirrors + (id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?, ?)", + ) + .bind(&id) + .bind(&input.name) + .bind(&input.index_url) + .bind(input.trusted_host.as_deref()) + .bind(input.enabled) + .bind(now) + .bind(now) + .execute(pool) + .await + .map_err(map_mirror_error)?; + + get(pool, &id).await +} + +pub async fn update( + pool: &SqlitePool, + id: &str, + input: ValidatedMirror, +) -> ApiResult { + if index_url_exists(pool, &input.index_url, Some(id)).await? { + return Err(ApiError::mirror_conflict("该索引地址已经存在")); + } + + let now = Utc::now(); + let result = sqlx::query( + "UPDATE python_mirrors + SET name = ?, index_url = ?, trusted_host = ?, enabled = ?, + is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END, + updated_at = ? + WHERE id = ?", + ) + .bind(&input.name) + .bind(&input.index_url) + .bind(input.trusted_host.as_deref()) + .bind(input.enabled) + .bind(input.enabled) + .bind(now) + .bind(id) + .execute(pool) + .await + .map_err(map_mirror_error)?; + + if result.rows_affected() == 0 { + return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在"))); + } + get(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> { + let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在"))); + } + Ok(()) +} + +/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。 +pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult { + let now = Utc::now(); + let mut transaction = pool.begin().await?; + + let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?") + .bind(id) + .fetch_optional(&mut *transaction) + .await?; + if exists.is_none() { + transaction.rollback().await?; + return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在"))); + } + + sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1") + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + "UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?", + ) + .bind(now) + .bind(id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + get(pool, id).await +} + +/// 执行器读取:只返回「启用且是默认」的镜像。 +pub async fn default_enabled(pool: &SqlitePool) -> ApiResult> { + let row = sqlx::query_as::<_, (String, String, Option)>( + "SELECT name, index_url, trusted_host + FROM python_mirrors + WHERE enabled = 1 AND is_default = 1 + LIMIT 1", + ) + .fetch_optional(pool) + .await?; + Ok( + row.map(|(name, index_url, trusted_host)| DefaultPythonMirror { + name, + index_url, + trusted_host, + }), + ) +} + +async fn index_url_exists( + pool: &SqlitePool, + index_url: &str, + excluding_id: Option<&str>, +) -> ApiResult { + let existing: Option<(String,)> = match excluding_id { + Some(id) => { + sqlx::query_as( + "SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?", + ) + .bind(index_url) + .bind(id) + .fetch_optional(pool) + .await? + } + None => { + sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)") + .bind(index_url) + .fetch_optional(pool) + .await? + } + }; + Ok(existing.is_some()) +} + +fn map_mirror_error(error: sqlx::Error) -> ApiError { + if let sqlx::Error::Database(database_error) = &error + && database_error.is_unique_violation() + { + return ApiError::mirror_conflict("该索引地址已经存在"); + } + ApiError::from(error) +} diff --git a/backend/src/python_mirrors/routes.rs b/backend/src/python_mirrors/routes.rs new file mode 100644 index 0000000..b3c5f9c --- /dev/null +++ b/backend/src/python_mirrors/routes.rs @@ -0,0 +1,151 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; + +use crate::{ + error::{ApiJson, ApiResult}, + python_mirrors::{ + model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest}, + repository, service, + }, + state::AppState, +}; + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/python/mirrors", + get(list_python_mirrors).post(create_python_mirror), + ) + .route( + "/api/v1/python/mirrors/{id}", + get(get_python_mirror) + .put(update_python_mirror) + .delete(delete_python_mirror), + ) + .route( + "/api/v1/python/mirrors/{id}/default", + post(set_default_python_mirror), + ) +} + +#[utoipa::path( + get, + path = "/api/v1/python/mirrors", + tag = "python-mirrors", + responses( + (status = 200, description = "Python package mirrors", body = Vec) + ) +)] +pub async fn list_python_mirrors( + State(state): State, +) -> ApiResult>> { + Ok(Json(repository::list(&state.pool).await?)) +} + +#[utoipa::path( + post, + path = "/api/v1/python/mirrors", + tag = "python-mirrors", + request_body = CreatePythonMirrorRequest, + responses( + (status = 201, description = "Mirror created", body = PythonMirrorResponse), + (status = 400, description = "Validation failed", body = crate::error::ErrorResponse), + (status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse) + ) +)] +pub async fn create_python_mirror( + State(state): State, + ApiJson(request): ApiJson, +) -> ApiResult<(StatusCode, Json)> { + let input = service::validate( + &request.name, + &request.index_url, + request.trusted_host.as_deref(), + request.enabled, + )?; + let mirror = repository::create(&state.pool, input).await?; + Ok((StatusCode::CREATED, Json(mirror))) +} + +#[utoipa::path( + get, + path = "/api/v1/python/mirrors/{id}", + tag = "python-mirrors", + params(("id" = String, Path, description = "Mirror identifier")), + responses( + (status = 200, description = "Mirror detail", body = PythonMirrorResponse), + (status = 404, description = "Mirror not found", body = crate::error::ErrorResponse) + ) +)] +pub async fn get_python_mirror( + State(state): State, + Path(id): Path, +) -> ApiResult> { + Ok(Json(repository::get(&state.pool, &id).await?)) +} + +#[utoipa::path( + put, + path = "/api/v1/python/mirrors/{id}", + tag = "python-mirrors", + params(("id" = String, Path, description = "Mirror identifier")), + request_body = UpdatePythonMirrorRequest, + responses( + (status = 200, description = "Mirror updated", body = PythonMirrorResponse), + (status = 400, description = "Validation failed", body = crate::error::ErrorResponse), + (status = 404, description = "Mirror not found", body = crate::error::ErrorResponse), + (status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse) + ) +)] +pub async fn update_python_mirror( + State(state): State, + Path(id): Path, + ApiJson(request): ApiJson, +) -> ApiResult> { + let input = service::validate( + &request.name, + &request.index_url, + request.trusted_host.as_deref(), + request.enabled, + )?; + Ok(Json(repository::update(&state.pool, &id, input).await?)) +} + +#[utoipa::path( + delete, + path = "/api/v1/python/mirrors/{id}", + tag = "python-mirrors", + params(("id" = String, Path, description = "Mirror identifier")), + responses( + (status = 204, description = "Mirror deleted"), + (status = 404, description = "Mirror not found", body = crate::error::ErrorResponse) + ) +)] +pub async fn delete_python_mirror( + State(state): State, + Path(id): Path, +) -> ApiResult { + repository::delete(&state.pool, &id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v1/python/mirrors/{id}/default", + tag = "python-mirrors", + params(("id" = String, Path, description = "Mirror identifier")), + responses( + (status = 200, description = "Default mirror updated", body = PythonMirrorResponse), + (status = 404, description = "Mirror not found", body = crate::error::ErrorResponse) + ) +)] +pub async fn set_default_python_mirror( + State(state): State, + Path(id): Path, +) -> ApiResult> { + Ok(Json(repository::set_default(&state.pool, &id).await?)) +} diff --git a/backend/src/python_mirrors/service.rs b/backend/src/python_mirrors/service.rs new file mode 100644 index 0000000..e5b094d --- /dev/null +++ b/backend/src/python_mirrors/service.rs @@ -0,0 +1,79 @@ +use url::Url; + +use crate::error::{ApiError, ApiResult}; + +const MAX_NAME_CHARS: usize = 64; +const MAX_URL_CHARS: usize = 2048; + +/// 校验并规范化后的镜像输入。 +#[derive(Debug)] +pub struct ValidatedMirror { + pub name: String, + pub index_url: String, + pub trusted_host: Option, + pub enabled: bool, +} + +pub fn validate( + name: &str, + index_url: &str, + trusted_host: Option<&str>, + enabled: bool, +) -> ApiResult { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::validation("name", "请输入镜像名称")); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(ApiError::validation( + "name", + format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"), + )); + } + + let index_url = index_url.trim(); + if index_url.is_empty() { + return Err(ApiError::validation("index_url", "请输入索引地址")); + } + if index_url.chars().count() > MAX_URL_CHARS { + return Err(ApiError::validation("index_url", "索引地址过长")); + } + let parsed = Url::parse(index_url) + .map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(ApiError::validation( + "index_url", + "索引地址只支持 http 或 https", + )); + } + if parsed.host_str().is_none() { + return Err(ApiError::validation("index_url", "索引地址缺少主机名")); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(ApiError::validation( + "index_url", + "索引地址不能包含用户名或密码", + )); + } + + let trusted_host = trusted_host + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) { + return Err(ApiError::validation( + "trusted_host", + "信任主机只填主机名,可带端口,例如 mirrors.aliyun.com", + )); + } + Ok(value.to_owned()) + }) + .transpose()?; + + Ok(ValidatedMirror { + name: name.to_owned(), + index_url: index_url.to_owned(), + trusted_host, + enabled, + }) +} diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index 49647b3..7fbde86 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, path::{Path, PathBuf}, process::Stdio, sync::{ @@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken; use crate::{ error::ApiResult, process_job::ProcessJob, - python_env::prepare_script_environment, + python_env::{is_python_interpreter, prepare_script_environment}, runs::{ model::{RunStatus, RunTrigger, TaskRunResponse}, repository, @@ -447,11 +447,48 @@ impl TaskExecutor { }; let mut command = Command::new(&prepared.interpreter); prepared.apply(&mut command); + + let mut notes = Vec::new(); + if let Some(venv) = &prepared.virtual_env { + notes.push(format!("使用 Python 虚拟环境: {}", venv.display())); + } + if is_python_interpreter(interpreter) { + match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await + { + Ok(Some(mirror)) => { + if !env_has(env, "PIP_INDEX_URL") { + command.env("PIP_INDEX_URL", &mirror.index_url); + } + if !env_has(env, "UV_INDEX_URL") { + command.env("UV_INDEX_URL", &mirror.index_url); + } + if let Some(host) = mirror + .trusted_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + && !env_has(env, "PIP_TRUSTED_HOST") + { + command.env("PIP_TRUSTED_HOST", host); + } + notes.push(format!( + "Python 包镜像: {} ({})", + mirror.name, mirror.index_url + )); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + task_id = %task.id, + %error, + "failed to load default python mirror" + ); + } + } + } + command.arg(script).args(args).current_dir(working_dir); - let startup_note = prepared - .virtual_env - .as_ref() - .map(|venv| format!("使用 Python 虚拟环境: {}", venv.display())); + let startup_note = (!notes.is_empty()).then(|| notes.join(";")); self.run_command(task, run_id, cancellation, command, startup_note) .await } @@ -1064,6 +1101,11 @@ async fn wait_after_termination(child: &mut Child) { } } +fn env_has(env: &BTreeMap, key: &str) -> bool { + env.keys() + .any(|candidate| candidate.eq_ignore_ascii_case(key)) +} + fn sensitive_values(action: &TaskAction) -> Vec { match action { TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env diff --git a/backend/tests/mirrors.rs b/backend/tests/mirrors.rs new file mode 100644 index 0000000..529ed9d --- /dev/null +++ b/backend/tests/mirrors.rs @@ -0,0 +1,243 @@ +use axum::{ + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn test_app() -> (axum::Router, TempDir) { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let pool = task_pilot_backend::database::connect("sqlite::memory:") + .await + .expect("test database should initialize"); + let app = task_pilot_backend::app(pool, temp_dir.path().join("logs")) + .await + .expect("test app should start"); + (app, temp_dir) +} + +async fn auth_token(app: &axum::Router) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/session") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + let json: Value = serde_json::from_slice(&body).expect("session should be JSON"); + json["token"] + .as_str() + .expect("token should be a string") + .to_owned() +} + +async fn send_json( + app: &axum::Router, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, +) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header("x-taskpilot-token", token); + } + let request = if let Some(body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&body).expect("body should serialize"), + )) + .expect("request should build") + } else { + builder.body(Body::empty()).expect("request should build") + }; + + let response = app + .clone() + .oneshot(request) + .await + .expect("router should respond"); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("body should be readable") + .to_bytes(); + let json = serde_json::from_slice(&body).unwrap_or(Value::Null); + (status, json) +} + +#[tokio::test] +async fn seeds_python_mirrors_with_one_default() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await; + + assert_eq!(status, StatusCode::OK); + let mirrors = body.as_array().expect("mirrors should be an array"); + assert_eq!(mirrors.len(), 4); + let defaults: Vec<&Value> = mirrors + .iter() + .filter(|mirror| mirror["is_default"] == true) + .collect(); + assert_eq!(defaults.len(), 1); + assert_eq!(defaults[0]["id"], "tsinghua"); + assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true)); +} + +#[tokio::test] +async fn mirrors_crud_and_default_switch() { + let (app, _temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, created) = send_json( + &app, + Method::POST, + "/api/v1/python/mirrors", + Some(&token), + Some(json!({ + "name": "内网镜像", + "index_url": "https://pypi.example.local/simple", + "trusted_host": "pypi.example.local", + "enabled": true + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let id = created["id"] + .as_str() + .expect("created mirror should have an id") + .to_owned(); + assert_eq!(created["is_default"], false); + assert_eq!(created["trusted_host"], "pypi.example.local"); + + let (status, mirror) = send_json( + &app, + Method::POST, + &format!("/api/v1/python/mirrors/{id}/default"), + Some(&token), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(mirror["is_default"], true); + + let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await; + assert_eq!(status, StatusCode::OK); + let defaults = list + .as_array() + .expect("mirrors should be an array") + .iter() + .filter(|mirror| mirror["is_default"] == true) + .count(); + assert_eq!(defaults, 1); + + let (status, updated) = send_json( + &app, + Method::PUT, + &format!("/api/v1/python/mirrors/{id}"), + Some(&token), + Some(json!({ + "name": "内网镜像(停用)", + "index_url": "https://pypi.example.local/simple", + "trusted_host": null, + "enabled": false + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated["name"], "内网镜像(停用)"); + assert_eq!(updated["enabled"], false); + // 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源 + assert_eq!(updated["is_default"], false); + + let (status, _) = send_json( + &app, + Method::DELETE, + &format!("/api/v1/python/mirrors/{id}"), + Some(&token), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let (status, body) = send_json( + &app, + Method::GET, + &format!("/api/v1/python/mirrors/{id}"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "MIRROR_NOT_FOUND"); +} + +#[tokio::test] +async fn rejects_invalid_mirror_url_and_duplicate_index() { + let (app, _temp_dir) = test_app().await; + let token = auth_token(&app).await; + + let (status, body) = send_json( + &app, + Method::POST, + "/api/v1/python/mirrors", + Some(&token), + Some(json!({ + "name": "坏地址", + "index_url": "ftp://example.com/simple", + "enabled": true + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "index_url"); + + let (status, body) = send_json( + &app, + Method::POST, + "/api/v1/python/mirrors", + Some(&token), + Some(json!({ + "name": "重复镜像", + "index_url": "https://pypi.org/simple", + "enabled": true + })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "MIRROR_CONFLICT"); +} + +#[tokio::test] +async fn mirror_mutations_require_token() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = send_json( + &app, + Method::POST, + "/api/v1/python/mirrors", + None, + Some(json!({ + "name": "未授权", + "index_url": "https://example.com/simple", + "enabled": true + })), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); +} diff --git a/contracts/openapi.json b/contracts/openapi.json index d433fb3..71f1809 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -89,6 +89,260 @@ } } }, + "/api/v1/python/mirrors": { + "get": { + "tags": [ + "python-mirrors" + ], + "operationId": "list_python_mirrors", + "responses": { + "200": { + "description": "Python package mirrors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PythonMirrorResponse" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "python-mirrors" + ], + "operationId": "create_python_mirror", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePythonMirrorRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Mirror created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PythonMirrorResponse" + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Mirror URL already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/python/mirrors/{id}": { + "get": { + "tags": [ + "python-mirrors" + ], + "operationId": "get_python_mirror", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Mirror identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Mirror detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PythonMirrorResponse" + } + } + } + }, + "404": { + "description": "Mirror not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "python-mirrors" + ], + "operationId": "update_python_mirror", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Mirror identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePythonMirrorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Mirror updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PythonMirrorResponse" + } + } + } + }, + "400": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Mirror not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Mirror URL already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "python-mirrors" + ], + "operationId": "delete_python_mirror", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Mirror identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Mirror deleted" + }, + "404": { + "description": "Mirror not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/python/mirrors/{id}/default": { + "post": { + "tags": [ + "python-mirrors" + ], + "operationId": "set_default_python_mirror", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Mirror identifier", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Default mirror updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PythonMirrorResponse" + } + } + } + }, + "404": { + "description": "Mirror not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/python/venv": { "post": { "tags": [ @@ -876,6 +1130,31 @@ "skip" ] }, + "CreatePythonMirrorRequest": { + "type": "object", + "required": [ + "name", + "index_url" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "index_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "trusted_host": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "CreateTaskRequest": { "type": "object", "required": [ @@ -1095,6 +1374,53 @@ } } }, + "PythonMirrorResponse": { + "type": "object", + "description": "一个 Python 包索引镜像(pip / uv)。", + "required": [ + "id", + "name", + "index_url", + "enabled", + "is_default", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "index_url": { + "type": "string", + "description": "pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`" + }, + "is_default": { + "type": "boolean", + "description": "当前默认镜像;同一时间最多一个" + }, + "name": { + "type": "string" + }, + "trusted_host": { + "type": [ + "string", + "null" + ], + "description": "pip 的 `--trusted-host`;https 源通常为空" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "RetryPolicy": { "type": "object", "properties": { @@ -1539,6 +1865,32 @@ } } }, + "UpdatePythonMirrorRequest": { + "type": "object", + "required": [ + "name", + "index_url", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "index_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "trusted_host": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "UpdateTaskRequest": { "type": "object", "required": [ diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index c0c53ed..6d32580 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -246,7 +246,9 @@ test('renders without external network access', async ({ page }) => { }) await page.goto('/') - await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible() + await expect( + page.getByRole('heading', { name: '任务调度' }), + ).toBeVisible() const state = await page.evaluate(async () => { // 字体二进制不入库;未生成时应回退系统字体,而不是报错。 @@ -331,3 +333,47 @@ test('keeps a long run log scrollable without rendering every line', async ({ fullPage: true, }) }) + +test('manages python package mirrors', async ({ page, request }) => { + const headers = await authHeaders(request) + + // 失败重跑时先清理上一次留下的测试镜像,并把默认源还原。 + const existing = await request.get('/api/v1/python/mirrors') + expect(existing.ok()).toBeTruthy() + const existingMirrors = (await existing.json()) as Array<{ + id: string + index_url: string + }> + for (const mirror of existingMirrors) { + if (mirror.index_url.includes('pypi.e2e.example')) { + await request.delete(`/api/v1/python/mirrors/${mirror.id}`, { headers }) + } + } + await request.post('/api/v1/python/mirrors/tsinghua/default', { headers }) + + await page.goto('/mirrors') + await expect( + page.getByRole('heading', { name: 'Python 镜像' }), + ).toBeVisible() + await expect(page.getByRole('row', { name: /清华 TUNA/ })).toBeVisible() + + await page.getByRole('button', { name: '+ 新增镜像' }).click() + const dialog = page.getByRole('dialog', { name: '新增 Python 镜像' }) + await dialog.getByLabel('名称').fill('E2E 镜像') + await dialog.getByLabel('索引地址').fill('https://pypi.e2e.example/simple') + await dialog.getByRole('button', { name: '保存' }).click() + + const row = page.getByRole('row', { name: /E2E 镜像/ }) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '设为默认' }).click() + await expect(row.getByText('默认', { exact: true })).toBeVisible() + + await row.getByRole('button', { name: '删除' }).click() + await page + .getByRole('dialog', { name: /删除镜像/ }) + .getByRole('button', { name: '确认删除' }) + .click() + await expect(row).toHaveCount(0) + + await request.post('/api/v1/python/mirrors/tsinghua/default', { headers }) +}) diff --git a/frontend/src/App.css b/frontend/src/App.css index 36e9abb..3877738 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -4,13 +4,68 @@ * 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。 */ -.app-shell { +/* ---------- 应用外壳:左侧导航 + 内容 ---------- */ + +.app-root { + min-height: 100vh; +} + +.app-sider { + position: sticky; + top: 0; display: flex; - width: min(1040px, calc(100% - 48px)); + height: 100vh; + box-sizing: border-box; flex-direction: column; gap: 24px; + overflow-y: auto; + padding: 24px 16px; + background-color: var(--su-bg-card); +} + +.app-sider-brand { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 8px; +} + +.app-nav { + margin-top: 4px; +} + +.app-sider-foot { + margin-top: auto; + border-top: 1px solid var(--su-border); + padding: 16px 8px 0; +} + +.app-sider-status { + display: flex; + flex-direction: column; + gap: 8px; +} + +.page-container { + width: 100%; + max-width: 1200px; + min-width: 0; margin: 0 auto; - padding: 48px 0; + padding: 32px 32px 48px; +} + +.page-stack { + display: flex; + min-width: 0; + flex-direction: column; + gap: 24px; +} + +/* 窄屏时表格自己横向滚动,不要把整个页面撑宽 */ +.page-stack > *, +.task-card, +.task-table { + min-width: 0; } /* ---------- 页头 ---------- */ @@ -876,8 +931,119 @@ background-color: var(--su-gray-6); } +/* ---------- Python 镜像 ---------- */ + +.mirror-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} + +.mirror-summary-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.mirror-summary-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.mirror-card { + overflow: hidden; +} + +.mirror-table { + border: 0; + border-radius: 0; +} + +.mirror-url { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mirror-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.mirror-enabled-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + /* ---------- 响应式 ---------- */ +@media (max-width: 1100px) { + /* Layout 在窄屏把侧栏堆到上方;它的内部 main/inner 还保留内容最小宽度, + 表格这类宽内容会把整页撑开,这里强制回到容器宽度。 */ + .app-root > div, + .app-root > div > div, + .app-root main, + .app-root main > div { + width: 100%; + min-width: 0; + } + + .app-sider { + position: static; + height: auto; + flex-direction: row; + align-items: center; + gap: 16px; + overflow: visible; + padding: 12px 16px; + } + + .app-sider-brand { + flex-direction: row; + align-items: center; + gap: 10px; + padding: 0; + } + + .app-sider-tagline { + display: none; + } + + .app-nav { + flex: 1; + margin-top: 0; + } + + /* Menu 内部是纵向 ul,窄屏改横向 */ + .app-nav > ul { + flex-direction: row; + gap: 4px; + } + + .app-nav > ul > li { + width: auto; + } + + .app-nav > ul button { + width: auto; + } + + .app-sider-foot { + margin-top: 0; + border-top: 0; + padding: 0; + } +} + @media (max-width: 1040px) { .run-log-body { grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr); @@ -885,10 +1051,17 @@ } @media (max-width: 760px) { - .app-shell { - width: min(100% - 24px, 1040px); - gap: 20px; - padding: 24px 0 40px; + .page-container { + padding: 24px 20px 40px; + } + + .app-sider { + gap: 10px; + padding: 10px 12px; + } + + .app-sider-foot { + display: none; } .page-header { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 768b72c..acb2e2e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -83,6 +83,20 @@ function createFetchMock(options: FetchMockOptions = {}) { { task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' }, ]) } + if (url.endsWith('/api/v1/python/mirrors')) { + return jsonResponse([ + { + id: 'tsinghua', + name: '清华 TUNA', + index_url: 'https://pypi.tuna.tsinghua.edu.cn/simple', + trusted_host: null, + enabled: true, + is_default: true, + created_at: '2026-10-18T10:00:00Z', + updated_at: '2026-10-18T10:00:00Z', + }, + ]) + } if (url.endsWith('/api/v1/cron/preview')) { return jsonResponse({ timezone: 'Asia/Shanghai', @@ -167,6 +181,23 @@ describe('TaskDashboard', () => { expect(await screen.findByText('在线')).toBeInTheDocument() }) + it('navigates to python mirrors from the sidebar', async () => { + vi.stubGlobal('fetch', createFetchMock()) + const user = userEvent.setup() + renderApp() + + await user.click( + await screen.findByRole('button', { name: 'Python 镜像' }), + ) + + expect( + await screen.findByRole('heading', { name: 'Python 镜像' }), + ).toBeInTheDocument() + expect( + await screen.findByRole('row', { name: /清华 TUNA/ }), + ).toBeInTheDocument() + }) + it('shows the next run time reported by the schedule endpoint', async () => { vi.stubGlobal('fetch', createFetchMock()) renderApp() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbdd2f6..e4303cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ -import { Button, Result } from '@stash/ui' +import { Button, Layout, Result } from '@stash/ui' import { Route, Routes, useNavigate } from 'react-router-dom' +import { AppSidebar } from './components/AppSidebar' +import { PythonMirrorsPage } from './pages/PythonMirrorsPage' import { TaskDashboard } from './pages/TaskDashboard' import './App.css' @@ -7,23 +9,34 @@ function NotFoundPage() { const navigate = useNavigate() return ( -
+
navigate('/')}>返回任务列表} /> -
+ ) } function App() { return ( - - } /> - } /> - + } + siderWidth={224} + maxWidth="full" + contentPadding="none" + > +
+ + } /> + } /> + } /> + +
+
) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index be327e6..cf66c5e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,6 +26,11 @@ export type DirectoryListingResponse = components['schemas']['DirectoryListingResponse'] export type TaskStats = components['schemas']['TaskStatsResponse'] export type ScheduledTask = components['schemas']['ScheduledTaskResponse'] +export type PythonMirror = components['schemas']['PythonMirrorResponse'] +export type CreatePythonMirrorRequest = + components['schemas']['CreatePythonMirrorRequest'] +export type UpdatePythonMirrorRequest = + components['schemas']['UpdatePythonMirrorRequest'] export interface TaskFilters { q?: string @@ -303,3 +308,42 @@ export function fetchStats(signal?: AbortSignal): Promise { export function fetchSchedule(signal?: AbortSignal): Promise { return requestJson('/api/v1/schedule', { signal }) } + +/** Python 包镜像(默认镜像会在脚本任务运行时注入 pip / uv 环境变量)。 */ +export function listPythonMirrors( + signal?: AbortSignal, +): Promise { + return requestJson('/api/v1/python/mirrors', { signal }) +} + +export function createPythonMirror( + request: CreatePythonMirrorRequest, +): Promise { + return requestJson('/api/v1/python/mirrors', { + method: 'POST', + body: JSON.stringify(request), + }) +} + +export function updatePythonMirror( + id: string, + request: UpdatePythonMirrorRequest, +): Promise { + return requestJson(`/api/v1/python/mirrors/${id}`, { + method: 'PUT', + body: JSON.stringify(request), + }) +} + +export function deletePythonMirror(id: string): Promise { + return requestJson(`/api/v1/python/mirrors/${id}`, { + method: 'DELETE', + }) +} + +export function setDefaultPythonMirror(id: string): Promise { + return requestJson( + `/api/v1/python/mirrors/${id}/default`, + { method: 'POST' }, + ) +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 546d45d..195ae70 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -52,6 +52,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/python/mirrors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_python_mirrors"]; + put?: never; + post: operations["create_python_mirror"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/python/mirrors/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_python_mirror"]; + put: operations["update_python_mirror"]; + post?: never; + delete: operations["delete_python_mirror"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/python/mirrors/{id}/default": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["set_default_python_mirror"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/python/venv": { parameters: { query?: never; @@ -298,6 +346,12 @@ export interface components { schemas: { /** @enum {string} */ ConcurrencyPolicy: "skip"; + CreatePythonMirrorRequest: { + enabled?: boolean; + index_url: string; + name: string; + trusted_host?: string | null; + }; CreateTaskRequest: { action: components["schemas"]["TaskAction"]; concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; @@ -360,6 +414,22 @@ export interface components { /** Format: int64 */ next_offset: number; }; + /** @description 一个 Python 包索引镜像(pip / uv)。 */ + PythonMirrorResponse: { + /** Format: date-time */ + created_at: string; + enabled: boolean; + id: string; + /** @description pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL` */ + index_url: string; + /** @description 当前默认镜像;同一时间最多一个 */ + is_default: boolean; + name: string; + /** @description pip 的 `--trusted-host`;https 源通常为空 */ + trusted_host?: string | null; + /** Format: date-time */ + updated_at: string; + }; RetryPolicy: { /** Format: int64 */ delay_secs?: number; @@ -501,6 +571,12 @@ export interface components { */ total: number; }; + UpdatePythonMirrorRequest: { + enabled: boolean; + index_url: string; + name: string; + trusted_host?: string | null; + }; UpdateTaskRequest: { action: components["schemas"]["TaskAction"]; concurrency_policy?: components["schemas"]["ConcurrencyPolicy"]; @@ -620,6 +696,216 @@ export interface operations { }; }; }; + list_python_mirrors: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Python package mirrors */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PythonMirrorResponse"][]; + }; + }; + }; + }; + create_python_mirror: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePythonMirrorRequest"]; + }; + }; + responses: { + /** @description Mirror created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PythonMirrorResponse"]; + }; + }; + /** @description Validation failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Mirror URL already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + get_python_mirror: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Mirror identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Mirror detail */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PythonMirrorResponse"]; + }; + }; + /** @description Mirror not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + update_python_mirror: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Mirror identifier */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdatePythonMirrorRequest"]; + }; + }; + responses: { + /** @description Mirror updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PythonMirrorResponse"]; + }; + }; + /** @description Validation failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Mirror not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Mirror URL already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete_python_mirror: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Mirror identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Mirror deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Mirror not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + set_default_python_mirror: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Mirror identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default mirror updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PythonMirrorResponse"]; + }; + }; + /** @description Mirror not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; probe_python_venv: { parameters: { query?: never; diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx new file mode 100644 index 0000000..b669fbd --- /dev/null +++ b/frontend/src/components/AppSidebar.tsx @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query' +import { Brand, Icon, Menu, Tag, Text, type MenuItem } from '@stash/ui' +import { useLocation, useNavigate } from 'react-router-dom' +import { fetchHealth } from '../api/client' + +const NAV_ITEMS: MenuItem[] = [ + { + key: '/', + label: '任务调度', + icon: , + }, + { + key: '/mirrors', + label: 'Python 镜像', + icon: , + }, +] + +/** + * 左侧导航:品牌 / 主导航 / 引擎状态。 + * + * 页面本身只负责内容,导航与全局状态收在这里;窄屏时 Layout 会把侧栏 + * 堆到内容上方,这里再用横向布局变形成顶部导航条。 + */ +export function AppSidebar() { + const location = useLocation() + const navigate = useNavigate() + const activeKey = location.pathname === '/mirrors' ? '/mirrors' : '/' + const healthQuery = useQuery({ + queryKey: ['health'], + queryFn: ({ signal }) => fetchHealth(signal), + refetchInterval: 30_000, + }) + + const status = healthQuery.isPending + ? { tone: 'neutral' as const, label: '检测中' } + : healthQuery.isError + ? { tone: 'danger' as const, label: '引擎离线' } + : { tone: 'success' as const, label: '引擎在线' } + + return ( +
+
+ + + 本地任务调度 · Windows + +
+ + navigate(key)} + itemHeight={38} + /> + +
+
+ + {status.label} + + + {healthQuery.data?.version + ? `Rust 引擎 v${healthQuery.data.version}` + : '127.0.0.1:3000'} + +
+
+
+ ) +} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..7bff8d0 --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,32 @@ +import { Space, Text, Title } from '@stash/ui' +import type { ReactNode } from 'react' + +interface PageHeaderProps { + eyebrow: string + title: string + subtitle?: ReactNode + actions?: ReactNode +} + +/** 页面标题区:左侧标题 / 说明,右侧操作。所有页面共用。 */ +export function PageHeader({ + eyebrow, + title, + subtitle, + actions, +}: PageHeaderProps) { + return ( +
+
+

{eyebrow}

+ {title} + {subtitle !== undefined ? {subtitle} : null} +
+ {actions !== undefined ? ( + + {actions} + + ) : null} +
+ ) +} diff --git a/frontend/src/components/PythonMirrorModal.tsx b/frontend/src/components/PythonMirrorModal.tsx new file mode 100644 index 0000000..345509a --- /dev/null +++ b/frontend/src/components/PythonMirrorModal.tsx @@ -0,0 +1,214 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + Checkbox, + FormField, + Input, + Modal, + Notice, + Text, +} from '@stash/ui' +import { useEffect, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { z } from 'zod' +import type { PythonMirror } from '../api/client' + +const mirrorSchema = z.object({ + name: z + .string() + .trim() + .min(1, '请输入镜像名称') + .max(64, '名称不能超过 64 个字符'), + index_url: z + .string() + .trim() + .min(1, '请输入索引地址') + .refine((value) => { + try { + const url = new URL(value) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + Boolean(url.hostname) && + !url.username && + !url.password + ) + } catch { + return false + } + }, '请输入 http(s) 开头的有效索引地址'), + trusted_host: z + .string() + .trim() + .refine( + (value) => + !value || (!value.includes('://') && !value.includes('/') && !/\s/.test(value)), + '只填主机名,可带端口,例如 mirrors.aliyun.com', + ), + enabled: z.boolean(), +}) + +export type PythonMirrorFormValues = z.infer + +function emptyValues(): PythonMirrorFormValues { + return { name: '', index_url: '', trusted_host: '', enabled: true } +} + +function toValues(mirror: PythonMirror): PythonMirrorFormValues { + return { + name: mirror.name, + index_url: mirror.index_url, + trusted_host: mirror.trusted_host ?? '', + enabled: mirror.enabled, + } +} + +interface PythonMirrorModalProps { + open: boolean + mirror: PythonMirror | null + saving: boolean + onClose: () => void + onSubmit: (values: PythonMirrorFormValues) => Promise +} + +export function PythonMirrorModal({ + open, + mirror, + saving, + onClose, + onSubmit, +}: PythonMirrorModalProps) { + const [submitError, setSubmitError] = useState(null) + const { + register, + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(mirrorSchema), + defaultValues: emptyValues(), + mode: 'onSubmit', + }) + + useEffect(() => { + if (!open) { + return + } + reset(mirror ? toValues(mirror) : emptyValues()) + setSubmitError(null) + }, [mirror, open, reset]) + + const requestClose = () => { + if (!saving) { + onClose() + } + } + + const submit = handleSubmit(async (values) => { + setSubmitError(null) + try { + await onSubmit(values) + } catch (error) { + setSubmitError(error instanceof Error ? error.message : '保存镜像失败') + } + }) + + return ( + + + + + } + > +
+ {submitError ? ( + + {submitError} + + ) : null} + + + + + + + + + + + + + + ( + + + 启用此镜像 + + + 停用后不会参与默认镜像选择 + + + } + /> + )} + /> + +
+ ) +} diff --git a/frontend/src/pages/PythonMirrorsPage.tsx b/frontend/src/pages/PythonMirrorsPage.tsx new file mode 100644 index 0000000..b5206ac --- /dev/null +++ b/frontend/src/pages/PythonMirrorsPage.tsx @@ -0,0 +1,275 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Empty, + Notice, + Result, + Space, + Spinner, + Table, + Tag, + Text, + TextButton, + Title, + toast, + type TableColumn, +} from '@stash/ui' +import { useState } from 'react' +import { + createPythonMirror, + deletePythonMirror, + listPythonMirrors, + setDefaultPythonMirror, + updatePythonMirror, + type PythonMirror, +} from '../api/client' +import { ConfirmDialog } from '../components/ConfirmDialog' +import { PageHeader } from '../components/PageHeader' +import { + PythonMirrorModal, + type PythonMirrorFormValues, +} from '../components/PythonMirrorModal' + +const TABLE_COLUMNS: TableColumn[] = [ + { key: 'name', title: '名称', width: 180 }, + { key: 'index_url', title: '索引地址', width: 330 }, + { key: 'trusted_host', title: '信任主机', width: 130 }, + { key: 'status', title: '状态', width: 100 }, + { key: 'actions', title: '操作', align: 'right', width: 220 }, +] + +function toRequest(values: PythonMirrorFormValues) { + return { + name: values.name.trim(), + index_url: values.index_url.trim(), + trusted_host: values.trusted_host.trim() || null, + enabled: values.enabled, + } +} + +/** + * Python 包镜像管理。 + * + * 数据在后端持久化,默认镜像会在 Python 脚本任务运行时注入 + * `PIP_INDEX_URL` / `UV_INDEX_URL`(任务自己的环境变量优先)。 + */ +export function PythonMirrorsPage() { + const queryClient = useQueryClient() + const [editorOpen, setEditorOpen] = useState(false) + const [editingMirror, setEditingMirror] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const mirrorsQuery = useQuery({ + queryKey: ['python-mirrors'], + queryFn: ({ signal }) => listPythonMirrors(signal), + }) + + const saveMutation = useMutation({ + mutationFn: ({ + mirror, + values, + }: { + mirror: PythonMirror | null + values: PythonMirrorFormValues + }) => + mirror + ? updatePythonMirror(mirror.id, toRequest(values)) + : createPythonMirror(toRequest(values)), + onSuccess: (mirror, variables) => { + setEditorOpen(false) + setEditingMirror(null) + toast.success( + variables.mirror ? `已更新 ${mirror.name}` : `已添加 ${mirror.name}`, + ) + void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] }) + }, + }) + + const defaultMutation = useMutation({ + mutationFn: (mirror: PythonMirror) => setDefaultPythonMirror(mirror.id), + onSuccess: (mirror) => { + toast.success(`默认镜像已切换为 ${mirror.name}`) + void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] }) + }, + onError: (error) => toast.error(error.message), + }) + + const deleteMutation = useMutation({ + mutationFn: (mirror: PythonMirror) => deletePythonMirror(mirror.id), + onSuccess: (_data, mirror) => { + setDeleteTarget(null) + toast.success(`已删除 ${mirror.name}`) + void queryClient.invalidateQueries({ queryKey: ['python-mirrors'] }) + }, + onError: (error) => toast.error(error.message), + }) + + const mirrors = mirrorsQuery.data ?? [] + const defaultMirror = mirrors.find((mirror) => mirror.is_default) ?? null + + const openCreate = () => { + setEditingMirror(null) + setEditorOpen(true) + } + + const openEdit = (mirror: PythonMirror) => { + setEditingMirror(mirror) + setEditorOpen(true) + } + + const rows = mirrors.map((mirror) => ({ + id: mirror.id, + name: ( + + {mirror.name} + {mirror.is_default ? ( + + 默认 + + ) : null} + + ), + index_url: ( + + {mirror.index_url} + + ), + trusted_host: mirror.trusted_host ? ( + + {mirror.trusted_host} + + ) : ( + + — + + ), + status: ( + + {mirror.enabled ? '已启用' : '已停用'} + + ), + actions: ( + + defaultMutation.mutate(mirror)} + > + 设为默认 + + openEdit(mirror)}>编辑 + setDeleteTarget(mirror)}> + 删除 + + + ), + })) + + return ( +
+ + 新增镜像} + /> + + +
+ + 当前默认镜像 + + + {defaultMirror?.name ?? '未设置默认镜像'} + + + {defaultMirror?.index_url ?? 'Python 任务不会注入镜像环境变量'} + +
+
+ + 运行时注入 + + + PIP_INDEX_URL + UV_INDEX_URL + {defaultMirror?.trusted_host ? ( + PIP_TRUSTED_HOST + ) : null} + +
+
+ + + 默认镜像在 Python 脚本任务启动时注入;任务自己配置的 PIP_INDEX_URL + 优先。修改后对下一次运行生效。 + + + + {mirrorsQuery.isError ? ( + void mirrorsQuery.refetch()} + > + 重试 + + } + /> + ) : mirrorsQuery.isPending ? ( +
+ + 正在读取镜像... +
+ ) : mirrors.length === 0 ? ( + 新增镜像} + /> + ) : ( + + )} + + + { + if (!saveMutation.isPending) { + setEditorOpen(false) + } + }} + onSubmit={async (values) => { + await saveMutation.mutateAsync({ mirror: editingMirror, values }) + }} + /> + + { + if (!deleteMutation.isPending) { + setDeleteTarget(null) + } + }} + onConfirm={() => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget) + } + }} + /> + + ) +} diff --git a/frontend/src/pages/TaskDashboard.tsx b/frontend/src/pages/TaskDashboard.tsx index 76e3424..ee83d1c 100644 --- a/frontend/src/pages/TaskDashboard.tsx +++ b/frontend/src/pages/TaskDashboard.tsx @@ -41,6 +41,7 @@ import { type TaskFilters, } from '../api/client' import { ConfirmDialog } from '../components/ConfirmDialog' +import { PageHeader } from '../components/PageHeader' import { RunLogDrawer } from '../components/RunLogDrawer' import { EnabledBadge } from '../components/StatusBadge' import { TaskEditorModal } from '../components/TaskEditorModal' @@ -68,12 +69,12 @@ const ACTION_TYPE_OPTIONS = [ ] const TABLE_COLUMNS: TableColumn[] = [ - { key: 'name', title: '名称 / 任务标识', width: 250 }, - { key: 'timezone', title: '时区', width: 120 }, - { key: 'schedule', title: '调度周期', width: 210 }, - { key: 'status', title: '状态', width: 110 }, - { key: 'timeout', title: '超时', width: 90 }, - { key: 'actions', title: '操作', align: 'right', width: 260 }, + { key: 'name', title: '名称 / 任务标识', width: 230 }, + { key: 'timezone', title: '时区', width: 110 }, + { key: 'schedule', title: '调度周期', width: 200 }, + { key: 'status', title: '状态', width: 100 }, + { key: 'timeout', title: '超时', width: 80 }, + { key: 'actions', title: '操作', align: 'right', width: 240 }, ] export function TaskDashboard() { @@ -322,17 +323,13 @@ export function TaskDashboard() { }) return ( -
-
-
-

TASKPILOT / WINDOWS SCHEDULER

- TaskPilot - Rust 本地轻量化任务引擎 · Windows 原生调度中心 -
- - - -
+
+ + 新建任务} + /> {actionError ? ( -
+ ) }