feat: 拆分任务类型并支持脚本语言联动
- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua) - 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览 - 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器 - TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
This commit is contained in:
@@ -118,7 +118,7 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
|||||||
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
| 观测 | 运行历史(状态/触发方式筛选)、退出码、耗时、错误信息、日志下载 |
|
||||||
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
| 日志 | SSE 实时推送 + 增量轮询 + 断线重连;编码自动识别;敏感值脱敏;容量与保留期清理 |
|
||||||
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
| Python | 自动关联 `.venv`/`venv`/`env`、解释器可选、用 uv 从 `pyproject.toml` / `requirements*.txt` 创建环境、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、默认 PyPI 镜像注入 |
|
||||||
| 界面 | 左侧导航(任务调度 / Python 镜像)、三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
| 界面 | 左侧导航(任务调度 / Python 镜像)、任务类型 + 脚本语言联动表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志弹窗 |
|
||||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||||
|
|
||||||
@@ -143,11 +143,15 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
|||||||
### 三类 Action
|
### 三类 Action
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// 脚本
|
// 脚本(language 省略时按 interpreter 推断,旧任务兼容)
|
||||||
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
|
{ "type": "script", "working_dir": "D:\\Projects\\sync", "venv": null,
|
||||||
"interpreter": "python.exe", "script": "main.py",
|
"language": "python", "interpreter": "python.exe", "script": "main.py",
|
||||||
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
|
"args": ["--mode", "full"], "env": { "PYTHONUNBUFFERED": "1" } }
|
||||||
|
|
||||||
|
// Shell:cmd.exe /C run.bat;Node/Lua 同理
|
||||||
|
{ "type": "script", "working_dir": "D:\\Projects\\ops", "language": "shell",
|
||||||
|
"interpreter": "cmd.exe", "script": "run.bat", "args": [], "env": {} }
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
|
{ "type": "http", "url": "https://api.example.com/health", "method": "GET",
|
||||||
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
|
"headers": { "X-Token": "..." }, "body": null, "expected_status": [200, 204] }
|
||||||
@@ -224,8 +228,8 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
|
- 没装 uv:必须手动准备虚拟环境;Python 任务缺少 `.venv` 时运行失败并给出提示。
|
||||||
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
|
- 既没有虚拟环境、也没有 pyproject/requirements:编辑器显示错误,Python 任务运行失败。
|
||||||
Python 任务不再静默回退到系统解释器。
|
Python 任务不再静默回退到系统解释器。
|
||||||
- 解释器字段是可选的:留空表示 Python,运行时会自动使用虚拟环境里的 `python.exe`;
|
- 解释器字段只对 Python 显示:留空会自动使用虚拟环境里的 `python.exe`;
|
||||||
Node / Shell 等非 Python 脚本再填写具体可执行程序。
|
选择 Shell / Node / Lua 时不需要手填解释器,分别固定使用 `cmd.exe` / `node.exe` / `lua.exe`。
|
||||||
|
|
||||||
命中虚拟环境后注入:
|
命中虚拟环境后注入:
|
||||||
|
|
||||||
@@ -246,9 +250,10 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
|
- **左侧导航**:任务调度 / Python 镜像两个入口;底部显示 Rust 引擎在线状态与版本。
|
||||||
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
- **指标卡**:已托管任务、运行中任务、可用脚本、引擎在线状态。
|
||||||
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
- **任务表**:名称/ID + 类型标签、时区、调度周期(Cron + **下次触发时间**)、状态、超时、操作。
|
||||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 3/5/8/10 次)、时区下拉、虚拟环境状态条、调用预览;
|
- **编辑器**:先选任务类型(脚本 / HTTP / 二进制),脚本类型再选语言(Python / Shell / Node / Lua),
|
||||||
探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。
|
非 Python 语言会自动隐藏虚拟环境字段并调整脚本占位符;Cron 预览(实时计算未来 3/5/8/10 次)、
|
||||||
解释器字段可选,留空自动使用虚拟环境 Python。
|
时区下拉、虚拟环境状态条、调用预览;探测到虚拟环境会自动回填「虚拟环境目录」,仍可手动修改;
|
||||||
|
缺少虚拟环境但项目有依赖文件时可直接用 uv 创建。解释器字段只在 Python 下显示,留空自动使用虚拟环境 Python。
|
||||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||||
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
- **Python 镜像**:内置清华 TUNA / 阿里云 / 中科大 / PyPI 官方;可新增、编辑、启用停用、设为默认。
|
||||||
@@ -407,7 +412,8 @@ Content-Type: application/json
|
|||||||
| `timeout_secs` | 1–86400 |
|
| `timeout_secs` | 1–86400 |
|
||||||
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
| `retry_policy` | `max_attempts ≤ 10`,`delay_secs ≤ 3600` |
|
||||||
| `action.working_dir` / `script` | 非空 |
|
| `action.working_dir` / `script` | 非空 |
|
||||||
| `action.interpreter` | 可空;留空时服务端默认成 `python.exe`,Python 任务会用虚拟环境解释器 |
|
| `action.language` | `python` / `shell` / `node` / `lua`;可省略,省略时按解释器推断 |
|
||||||
|
| `action.interpreter` | 可空;空时按语言默认(Python 为 `python.exe`,Shell 为 `cmd.exe`,Node 为 `node.exe`,Lua 为 `lua.exe`) |
|
||||||
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
| `action.venv` | 若填写,必须存在 `pyvenv.cfg` 与 `Scripts\python.exe` |
|
||||||
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
| `action.args` | ≤ 256 个,单个 ≤ 2048 字符 |
|
||||||
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
| `action.env` | ≤ 128 条;key ≤ 128、不含 `=`/`\0`;value ≤ 8192、不含 `\0` |
|
||||||
@@ -541,9 +547,9 @@ uv run --with fonttools --with brotli tools/build-fonts.py
|
|||||||
| 层 | 覆盖 |
|
| 层 | 覆盖 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
| Rust 单元(59) | Cron、虚拟环境解析与探测、项目依赖文件扫描、uv 可用性、执行器(编码/截断/清理/句柄归属/日志合并推送)、安全、目录列举 |
|
||||||
| Rust 集成(38) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建 |
|
| Rust 集成(39) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账、镜像 CRUD、venv 探测/创建、Shell 脚本执行 |
|
||||||
| 前端 Vitest(30) | 仪表盘渲染、侧栏导航到镜像页、表单载荷、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
| 前端 Vitest(31) | 仪表盘渲染、侧栏导航到镜像页、表单载荷与脚本语言、虚拟环境状态、uv 创建入口、目录浏览器、下次触发时间、日志行切分/窗口计算/虚拟滚动 |
|
||||||
| Playwright(6) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
| Playwright(7) | 真实任务执行与日志、编辑/停用/删除、脚本语言切换、窄屏、**无外部网络渲染**、1500 行日志的虚拟滚动、镜像增删与设默认 |
|
||||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-2
@@ -41,8 +41,8 @@ use crate::{
|
|||||||
tasks::{
|
tasks::{
|
||||||
model::{
|
model::{
|
||||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
||||||
TaskAction, TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse,
|
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||||
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,
|
||||||
@@ -299,6 +299,7 @@ pub async fn list_schedule(
|
|||||||
ScheduledTaskResponse,
|
ScheduledTaskResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
TaskActionType,
|
TaskActionType,
|
||||||
|
ScriptLanguage,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
ConcurrencyPolicy,
|
ConcurrencyPolicy,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use crate::{
|
use crate::{
|
||||||
error::ApiResult,
|
error::ApiResult,
|
||||||
process_job::ProcessJob,
|
process_job::ProcessJob,
|
||||||
python_env::{is_python_interpreter, prepare_script_environment},
|
python_env::prepare_script_environment,
|
||||||
runs::{
|
runs::{
|
||||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||||
repository,
|
repository,
|
||||||
},
|
},
|
||||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
@@ -429,11 +429,14 @@ impl TaskExecutor {
|
|||||||
TaskAction::Script {
|
TaskAction::Script {
|
||||||
working_dir,
|
working_dir,
|
||||||
venv,
|
venv,
|
||||||
|
language,
|
||||||
interpreter,
|
interpreter,
|
||||||
script,
|
script,
|
||||||
args,
|
args,
|
||||||
env,
|
env,
|
||||||
} => {
|
} => {
|
||||||
|
let script_language =
|
||||||
|
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||||
let prepared = match prepare_script_environment(
|
let prepared = match prepare_script_environment(
|
||||||
working_dir,
|
working_dir,
|
||||||
interpreter,
|
interpreter,
|
||||||
@@ -451,7 +454,7 @@ impl TaskExecutor {
|
|||||||
let mut notes = Vec::new();
|
let mut notes = Vec::new();
|
||||||
if let Some(venv) = &prepared.virtual_env {
|
if let Some(venv) = &prepared.virtual_env {
|
||||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||||
} else if is_python_interpreter(interpreter) {
|
} else if script_language == ScriptLanguage::Python {
|
||||||
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
||||||
let project = crate::python_env::scan_project_files(working_dir);
|
let project = crate::python_env::scan_project_files(working_dir);
|
||||||
if project.has_pyproject || !project.requirements.is_empty() {
|
if project.has_pyproject || !project.requirements.is_empty() {
|
||||||
@@ -463,7 +466,7 @@ impl TaskExecutor {
|
|||||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if is_python_interpreter(interpreter) {
|
if script_language == ScriptLanguage::Python {
|
||||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||||
{
|
{
|
||||||
Ok(Some(mirror)) => {
|
Ok(Some(mirror)) => {
|
||||||
@@ -501,6 +504,12 @@ impl TaskExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if script_language == ScriptLanguage::Shell
|
||||||
|
&& is_cmd_interpreter(interpreter)
|
||||||
|
&& !script.trim_start().starts_with('/')
|
||||||
|
{
|
||||||
|
command.arg("/C");
|
||||||
|
}
|
||||||
command.arg(script).args(args).current_dir(working_dir);
|
command.arg(script).args(args).current_dir(working_dir);
|
||||||
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
||||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||||
@@ -1120,6 +1129,13 @@ fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
|
|||||||
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_cmd_interpreter(interpreter: &str) -> bool {
|
||||||
|
Path::new(interpreter)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
|
||||||
|
}
|
||||||
|
|
||||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||||
match action {
|
match action {
|
||||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||||
|
|||||||
@@ -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, TaskAction};
|
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -215,6 +215,7 @@ mod tests {
|
|||||||
action: TaskAction::Script {
|
action: TaskAction::Script {
|
||||||
working_dir: ".".to_owned(),
|
working_dir: ".".to_owned(),
|
||||||
venv: None,
|
venv: None,
|
||||||
|
language: Some(ScriptLanguage::Shell),
|
||||||
interpreter: "cmd.exe".to_owned(),
|
interpreter: "cmd.exe".to_owned(),
|
||||||
script: "/C".to_owned(),
|
script: "/C".to_owned(),
|
||||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::{collections::BTreeMap, path::Path};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
|
|||||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ScriptLanguage {
|
||||||
|
Python,
|
||||||
|
Shell,
|
||||||
|
Node,
|
||||||
|
Lua,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptLanguage {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Python => "python",
|
||||||
|
Self::Shell => "shell",
|
||||||
|
Self::Node => "node",
|
||||||
|
Self::Lua => "lua",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn default_interpreter(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Python => "python.exe",
|
||||||
|
Self::Shell => "cmd.exe",
|
||||||
|
Self::Node => "node.exe",
|
||||||
|
Self::Lua => "lua.exe",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
|
||||||
|
pub fn from_interpreter(interpreter: &str) -> Self {
|
||||||
|
let stem = Path::new(interpreter)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.unwrap_or(interpreter)
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(stem.as_str(), "node" | "nodejs") {
|
||||||
|
Self::Node
|
||||||
|
} else if stem.starts_with("lua") {
|
||||||
|
Self::Lua
|
||||||
|
} else if matches!(
|
||||||
|
stem.as_str(),
|
||||||
|
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
|
||||||
|
) {
|
||||||
|
Self::Shell
|
||||||
|
} else {
|
||||||
|
Self::Python
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TaskActionType {
|
pub enum TaskActionType {
|
||||||
@@ -57,6 +107,8 @@ pub enum TaskAction {
|
|||||||
working_dir: String,
|
working_dir: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
venv: Option<String>,
|
venv: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<ScriptLanguage>,
|
||||||
interpreter: String,
|
interpreter: String,
|
||||||
script: String,
|
script: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -93,6 +145,18 @@ impl TaskAction {
|
|||||||
Self::Process { .. } => TaskActionType::Process,
|
Self::Process { .. } => TaskActionType::Process,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 脚本语言;旧任务未存 language 时从解释器推断。
|
||||||
|
pub fn script_language(&self) -> Option<ScriptLanguage> {
|
||||||
|
match self {
|
||||||
|
Self::Script {
|
||||||
|
language,
|
||||||
|
interpreter,
|
||||||
|
..
|
||||||
|
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_timezone() -> String {
|
fn default_timezone() -> String {
|
||||||
|
|||||||
@@ -184,11 +184,17 @@ fn validate_common(
|
|||||||
|
|
||||||
fn normalize_action(action: &mut TaskAction) {
|
fn normalize_action(action: &mut TaskAction) {
|
||||||
if let TaskAction::Script {
|
if let TaskAction::Script {
|
||||||
venv, interpreter, ..
|
venv,
|
||||||
|
language,
|
||||||
|
interpreter,
|
||||||
|
..
|
||||||
} = action
|
} = action
|
||||||
{
|
{
|
||||||
|
let resolved =
|
||||||
|
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
|
||||||
|
*language = Some(resolved);
|
||||||
if interpreter.trim().is_empty() {
|
if interpreter.trim().is_empty() {
|
||||||
*interpreter = "python.exe".to_owned();
|
*interpreter = resolved.default_interpreter().to_owned();
|
||||||
}
|
}
|
||||||
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||||
*venv = None;
|
*venv = None;
|
||||||
@@ -201,6 +207,7 @@ fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
|||||||
TaskAction::Script {
|
TaskAction::Script {
|
||||||
working_dir,
|
working_dir,
|
||||||
venv,
|
venv,
|
||||||
|
language: _,
|
||||||
interpreter,
|
interpreter,
|
||||||
script,
|
script,
|
||||||
args,
|
args,
|
||||||
@@ -348,6 +355,7 @@ mod tests {
|
|||||||
TaskAction::Script {
|
TaskAction::Script {
|
||||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||||
venv: None,
|
venv: None,
|
||||||
|
language: Some(ScriptLanguage::Python),
|
||||||
interpreter: "python.exe".to_owned(),
|
interpreter: "python.exe".to_owned(),
|
||||||
script: "main.py".to_owned(),
|
script: "main.py".to_owned(),
|
||||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||||
@@ -388,6 +396,7 @@ mod tests {
|
|||||||
let action = TaskAction::Script {
|
let action = TaskAction::Script {
|
||||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||||
venv: None,
|
venv: None,
|
||||||
|
language: Some(ScriptLanguage::Python),
|
||||||
interpreter: "python.exe".to_owned(),
|
interpreter: "python.exe".to_owned(),
|
||||||
script: " ".to_owned(),
|
script: " ".to_owned(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
|
|||||||
+53
-1
@@ -1,4 +1,4 @@
|
|||||||
use std::time::Duration;
|
use std::{fs, time::Duration};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@@ -201,6 +201,58 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
|||||||
panic!("run {run_id} did not reach a terminal state");
|
panic!("run {run_id} did not reach a terminal state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shell_language_runs_batch_file_with_cmd_c() {
|
||||||
|
let app = test_app().await;
|
||||||
|
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||||
|
fs::write(
|
||||||
|
app._temp_dir.path().join("hello.bat"),
|
||||||
|
"@echo off\r\necho shell-language-ok\r\n",
|
||||||
|
)
|
||||||
|
.expect("batch file should be written");
|
||||||
|
|
||||||
|
let mut task = script_task(
|
||||||
|
"shell_task",
|
||||||
|
&working_dir,
|
||||||
|
"cmd.exe",
|
||||||
|
"hello.bat",
|
||||||
|
vec![],
|
||||||
|
"0 0 2 * * *",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
task["action"]["language"] = json!("shell");
|
||||||
|
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||||
|
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let run_response = send_json(
|
||||||
|
&app.router,
|
||||||
|
Method::POST,
|
||||||
|
"/api/v1/tasks/shell_task/run",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||||
|
let run = response_json(run_response).await;
|
||||||
|
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||||
|
|
||||||
|
let completed = wait_for_run(&app.router, run_id).await;
|
||||||
|
assert_eq!(completed["status"], "succeeded");
|
||||||
|
let log_response = send_json(
|
||||||
|
&app.router,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/runs/{run_id}/logs"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let log = response_json(log_response).await;
|
||||||
|
assert!(
|
||||||
|
log["content"]
|
||||||
|
.as_str()
|
||||||
|
.expect("log content should be a string")
|
||||||
|
.contains("shell-language-ok")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||||
let app = test_app().await;
|
let app = test_app().await;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ async fn empty_interpreter_defaults_to_python() {
|
|||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
let created = response_json(response).await;
|
let created = response_json(response).await;
|
||||||
assert_eq!(created["action"]["interpreter"], "python.exe");
|
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||||
|
assert_eq!(created["action"]["language"], "python");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1530,6 +1530,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ScriptLanguage": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"python",
|
||||||
|
"shell",
|
||||||
|
"node",
|
||||||
|
"lua"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SessionResponse": {
|
"SessionResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1571,6 +1580,16 @@
|
|||||||
"interpreter": {
|
"interpreter": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ScriptLanguage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"script": {
|
"script": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -174,6 +174,36 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
await expect(page.getByText('还没有任务')).toBeVisible()
|
await expect(page.getByText('还没有任务')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('switches script language and adapts the form fields', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: '+ 新建任务' }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: '新建定时任务' })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: 'Python', exact: true }).click()
|
||||||
|
await page.getByRole('option', { name: 'Shell (cmd)' }).click()
|
||||||
|
await expect(dialog.getByText(/虚拟环境目录/)).toHaveCount(0)
|
||||||
|
await expect(dialog.getByLabel('批处理文件或相对路径')).toHaveValue('run.bat')
|
||||||
|
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||||
|
'cmd.exe /C run.bat',
|
||||||
|
)
|
||||||
|
|
||||||
|
await dialog.getByRole('button', { name: 'Shell (cmd)' }).click()
|
||||||
|
await page.getByRole('option', { name: 'Node.js' }).click()
|
||||||
|
await expect(dialog.getByLabel('JavaScript 文件或相对路径')).toHaveValue(
|
||||||
|
'index.js',
|
||||||
|
)
|
||||||
|
await expect(dialog.locator('.cli-line-cmd')).toContainText(
|
||||||
|
'node.exe index.js',
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: 'test-results/dashboard-script-language.png',
|
||||||
|
fullPage: true,
|
||||||
|
})
|
||||||
|
await dialog.getByRole('button', { name: '取消' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps the dashboard usable on a narrow viewport', async ({
|
test('keeps the dashboard usable on a narrow viewport', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -297,6 +297,20 @@
|
|||||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-hint {
|
||||||
|
display: flex;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--su-border);
|
||||||
|
border-radius: var(--su-radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--su-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
.form-select {
|
.form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,8 @@ export interface components {
|
|||||||
next_run_at?: string | null;
|
next_run_at?: string | null;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
};
|
};
|
||||||
|
/** @enum {string} */
|
||||||
|
ScriptLanguage: "python" | "shell" | "node" | "lua";
|
||||||
SessionResponse: {
|
SessionResponse: {
|
||||||
/** @description Random per-process token required for mutating browser requests. */
|
/** @description Random per-process token required for mutating browser requests. */
|
||||||
token: string;
|
token: string;
|
||||||
@@ -479,6 +481,7 @@ export interface components {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
interpreter: string;
|
interpreter: string;
|
||||||
|
language?: components["schemas"]["ScriptLanguage"] | null;
|
||||||
script: string;
|
script: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: "script";
|
type: "script";
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
|||||||
import {
|
import {
|
||||||
createEmptyTaskFormValues,
|
createEmptyTaskFormValues,
|
||||||
mapServerFieldToFormField,
|
mapServerFieldToFormField,
|
||||||
|
SCRIPT_LANGUAGE_META,
|
||||||
taskFormSchema,
|
taskFormSchema,
|
||||||
taskToFormValues,
|
taskToFormValues,
|
||||||
type KeyValueFormValue,
|
type KeyValueFormValue,
|
||||||
|
type ScriptLanguage,
|
||||||
type TaskFormValues,
|
type TaskFormValues,
|
||||||
} from '../lib/taskForm'
|
} from '../lib/taskForm'
|
||||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||||
@@ -52,11 +54,17 @@ interface TaskEditorModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_TYPE_OPTIONS = [
|
const ACTION_TYPE_OPTIONS = [
|
||||||
{ value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' },
|
{ value: 'script', label: '执行脚本' },
|
||||||
{ value: 'http', label: '发起 HTTP 请求' },
|
{ value: 'http', label: '发起 HTTP 请求' },
|
||||||
{ value: 'process', label: '管理常驻/独立二进制' },
|
{ value: 'process', label: '运行二进制程序' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const SCRIPT_LANGUAGE_OPTIONS = (
|
||||||
|
Object.entries(SCRIPT_LANGUAGE_META) as Array<
|
||||||
|
[ScriptLanguage, (typeof SCRIPT_LANGUAGE_META)[ScriptLanguage]]
|
||||||
|
>
|
||||||
|
).map(([value, meta]) => ({ value, label: meta.label }))
|
||||||
|
|
||||||
const HTTP_METHOD_OPTIONS = [
|
const HTTP_METHOD_OPTIONS = [
|
||||||
{ value: 'GET', label: 'GET' },
|
{ value: 'GET', label: 'GET' },
|
||||||
{ value: 'POST', label: 'POST' },
|
{ value: 'POST', label: 'POST' },
|
||||||
@@ -109,6 +117,9 @@ export function TaskEditorModal({
|
|||||||
const cronValue = watch('cron')
|
const cronValue = watch('cron')
|
||||||
const timezoneValue = watch('timezone')
|
const timezoneValue = watch('timezone')
|
||||||
const scriptPreview = watch('script')
|
const scriptPreview = watch('script')
|
||||||
|
const scriptLanguage = scriptPreview.language
|
||||||
|
const scriptMeta = SCRIPT_LANGUAGE_META[scriptLanguage]
|
||||||
|
const isPythonScript = scriptLanguage === 'python'
|
||||||
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
|
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
|
||||||
// 避免下拉显示成空值。
|
// 避免下拉显示成空值。
|
||||||
const timezoneOptions = Array.from(
|
const timezoneOptions = Array.from(
|
||||||
@@ -147,12 +158,42 @@ export function TaskEditorModal({
|
|||||||
Boolean(debouncedTimezone.trim()),
|
Boolean(debouncedTimezone.trim()),
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
const applyScriptLanguage = (language: ScriptLanguage) => {
|
||||||
|
const meta = SCRIPT_LANGUAGE_META[language]
|
||||||
|
const currentScript = getValues('script.script').trim()
|
||||||
|
const knownDefaults = Object.values(SCRIPT_LANGUAGE_META).map(
|
||||||
|
(item) => item.defaultScript,
|
||||||
|
)
|
||||||
|
setValue('script.language', language, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
setValue('script.interpreter', meta.defaultInterpreter, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
if (!currentScript || knownDefaults.includes(currentScript)) {
|
||||||
|
setValue('script.script', meta.defaultScript, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (language !== 'python') {
|
||||||
|
setValue('script.venv', '', {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
autoVenvRef.current = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const venvProbe = useQuery({
|
const venvProbe = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'python-venv',
|
'python-venv',
|
||||||
debouncedWorkingDir,
|
debouncedWorkingDir,
|
||||||
debouncedInterpreter,
|
debouncedInterpreter,
|
||||||
debouncedVenv,
|
debouncedVenv,
|
||||||
|
scriptLanguage,
|
||||||
],
|
],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
probePythonVenv(
|
probePythonVenv(
|
||||||
@@ -164,7 +205,10 @@ export function TaskEditorModal({
|
|||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
enabled:
|
enabled:
|
||||||
open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()),
|
open &&
|
||||||
|
actionType === 'script' &&
|
||||||
|
isPythonScript &&
|
||||||
|
Boolean(debouncedWorkingDir.trim()),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
})
|
})
|
||||||
@@ -185,10 +229,16 @@ export function TaskEditorModal({
|
|||||||
},
|
},
|
||||||
onError: (error) => toast.error(error.message),
|
onError: (error) => toast.error(error.message),
|
||||||
})
|
})
|
||||||
const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null
|
const linkedVenv =
|
||||||
const previewVirtualEnv =
|
isPythonScript && venvProbe.data?.status === 'linked'
|
||||||
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
? venvProbe.data
|
||||||
const previewInterpreter = linkedVenv?.display_path ?? null
|
: null
|
||||||
|
const previewVirtualEnv = isPythonScript
|
||||||
|
? scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||||
|
: ''
|
||||||
|
const previewInterpreter = isPythonScript
|
||||||
|
? (linkedVenv?.display_path ?? null)
|
||||||
|
: null
|
||||||
|
|
||||||
const scriptArgs = useFieldArray({ control, name: 'script.args' })
|
const scriptArgs = useFieldArray({ control, name: 'script.args' })
|
||||||
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
||||||
@@ -220,7 +270,7 @@ export function TaskEditorModal({
|
|||||||
|
|
||||||
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
|
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open || !isPythonScript) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const probe = venvProbe.data
|
const probe = venvProbe.data
|
||||||
@@ -245,7 +295,7 @@ export function TaskEditorModal({
|
|||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
})
|
})
|
||||||
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
|
}, [debouncedVenv, debouncedWorkingDir, getValues, isPythonScript, open, setValue, venvProbe.data])
|
||||||
|
|
||||||
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||||
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||||
@@ -475,6 +525,32 @@ export function TaskEditorModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionType === 'script' ? (
|
||||||
|
<div className="form-grid language-grid">
|
||||||
|
<FormField label="脚本语言">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="script.language"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
className="form-select"
|
||||||
|
options={SCRIPT_LANGUAGE_OPTIONS}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
applyScriptLanguage(value as ScriptLanguage)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="language-hint">
|
||||||
|
<Text size="sm" tone="sub">
|
||||||
|
{scriptMeta.hint}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{actionType === 'script' ? (
|
{actionType === 'script' ? (
|
||||||
<div className="dynamic-fields">
|
<div className="dynamic-fields">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -502,48 +578,52 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="form-grid venv-grid">
|
{isPythonScript ? (
|
||||||
<FormField
|
<>
|
||||||
label="虚拟环境目录 (可选)"
|
<div className="form-grid venv-grid">
|
||||||
htmlFor="task-venv"
|
<FormField
|
||||||
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
label="虚拟环境目录 (可选)"
|
||||||
error={errors.script?.venv?.message}
|
htmlFor="task-venv"
|
||||||
>
|
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
||||||
<Input
|
error={errors.script?.venv?.message}
|
||||||
id="task-venv"
|
>
|
||||||
{...register('script.venv')}
|
<Input
|
||||||
className="mono-input"
|
id="task-venv"
|
||||||
placeholder="例如:.venv"
|
{...register('script.venv')}
|
||||||
/>
|
className="mono-input"
|
||||||
</FormField>
|
placeholder="例如:.venv"
|
||||||
<FormField
|
/>
|
||||||
label="解释器(可选)"
|
</FormField>
|
||||||
htmlFor="task-interpreter"
|
<FormField
|
||||||
hint="Python 项目留空即可,自动使用虚拟环境里的 Python;Node / Shell 等再填"
|
label="解释器(可选)"
|
||||||
error={errors.script?.interpreter?.message}
|
htmlFor="task-interpreter"
|
||||||
>
|
hint="留空自动使用虚拟环境 Python;也可以填自定义 Python 路径"
|
||||||
<Input
|
error={errors.script?.interpreter?.message}
|
||||||
id="task-interpreter"
|
>
|
||||||
{...register('script.interpreter')}
|
<Input
|
||||||
className="mono-input"
|
id="task-interpreter"
|
||||||
placeholder="留空自动使用虚拟环境 Python"
|
{...register('script.interpreter')}
|
||||||
/>
|
className="mono-input"
|
||||||
</FormField>
|
placeholder="留空自动使用虚拟环境 Python"
|
||||||
</div>
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VenvDetectorBar
|
<VenvDetectorBar
|
||||||
workingDir={debouncedWorkingDir}
|
workingDir={debouncedWorkingDir}
|
||||||
probe={venvProbe.data}
|
probe={venvProbe.data}
|
||||||
pending={venvProbe.isPending}
|
pending={venvProbe.isPending}
|
||||||
failed={venvProbe.isError}
|
failed={venvProbe.isError}
|
||||||
creating={createVenvMutation.isPending}
|
creating={createVenvMutation.isPending}
|
||||||
onCreate={() =>
|
onCreate={() =>
|
||||||
createVenvMutation.mutate(scriptPreview.working_dir.trim())
|
createVenvMutation.mutate(scriptPreview.working_dir.trim())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="脚本文件或相对路径"
|
label={scriptMeta.scriptLabel}
|
||||||
htmlFor="task-script"
|
htmlFor="task-script"
|
||||||
error={errors.script?.script?.message}
|
error={errors.script?.script?.message}
|
||||||
>
|
>
|
||||||
@@ -551,7 +631,7 @@ export function TaskEditorModal({
|
|||||||
id="task-script"
|
id="task-script"
|
||||||
{...register('script.script')}
|
{...register('script.script')}
|
||||||
className="mono-input"
|
className="mono-input"
|
||||||
placeholder="main.py 或 scripts\sync.py"
|
placeholder={scriptMeta.scriptPlaceholder}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -637,9 +717,11 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="cli-line-cmd">
|
<div className="cli-line-cmd">
|
||||||
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
|
{formatScriptCommandPreview(
|
||||||
{scriptPreview.script.trim() || 'main.py'}
|
scriptPreview,
|
||||||
{formatArgumentPreview(scriptPreview.args)}
|
previewInterpreter,
|
||||||
|
scriptLanguage,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -877,12 +959,53 @@ function venvFieldValue(workingDir: string, venvPath: string): string {
|
|||||||
|
|
||||||
function formatInterpreterPreview(
|
function formatInterpreterPreview(
|
||||||
script: TaskFormValues['script'],
|
script: TaskFormValues['script'],
|
||||||
linkedPythonPath?: string | null,
|
linkedPythonPath: string | null | undefined,
|
||||||
|
language: ScriptLanguage,
|
||||||
): string {
|
): string {
|
||||||
if (script.venv.trim()) {
|
if (language === 'python' && script.venv.trim()) {
|
||||||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
||||||
}
|
}
|
||||||
return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe')
|
if (linkedPythonPath) {
|
||||||
|
return linkedPythonPath
|
||||||
|
}
|
||||||
|
if (script.interpreter.trim()) {
|
||||||
|
return script.interpreter.trim()
|
||||||
|
}
|
||||||
|
return language === 'python'
|
||||||
|
? 'python.exe'
|
||||||
|
: SCRIPT_LANGUAGE_META[language].defaultInterpreter
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCmdInterpreter(interpreter: string): boolean {
|
||||||
|
return (
|
||||||
|
interpreter
|
||||||
|
.trim()
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.toLowerCase()
|
||||||
|
.replace(/\.exe$/, '') === 'cmd'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScriptCommandPreview(
|
||||||
|
script: TaskFormValues['script'],
|
||||||
|
linkedPythonPath: string | null | undefined,
|
||||||
|
language: ScriptLanguage,
|
||||||
|
): string {
|
||||||
|
const interpreter = formatInterpreterPreview(
|
||||||
|
script,
|
||||||
|
linkedPythonPath,
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
const file =
|
||||||
|
script.script.trim() || SCRIPT_LANGUAGE_META[language].defaultScript
|
||||||
|
const cmdPrefix =
|
||||||
|
language === 'shell' &&
|
||||||
|
isCmdInterpreter(script.interpreter) &&
|
||||||
|
!file.startsWith('/')
|
||||||
|
? '/C '
|
||||||
|
: ''
|
||||||
|
return `${interpreter} ${cmdPrefix}${file}${formatArgumentPreview(script.args)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VenvDetectorBarProps {
|
interface VenvDetectorBarProps {
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ describe('task form payload', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('submits the selected script language and interpreter', () => {
|
||||||
|
const values = createEmptyTaskFormValues()
|
||||||
|
values.id = 'shell_task'
|
||||||
|
values.name = 'Shell task'
|
||||||
|
values.script.language = 'shell'
|
||||||
|
values.script.working_dir = 'D:\\Projects\\shell-task'
|
||||||
|
values.script.interpreter = 'cmd.exe'
|
||||||
|
values.script.script = 'run.bat'
|
||||||
|
|
||||||
|
const payload = valuesToCreateRequest(values)
|
||||||
|
|
||||||
|
expect(payload.action).toMatchObject({
|
||||||
|
type: 'script',
|
||||||
|
language: 'shell',
|
||||||
|
working_dir: 'D:\\Projects\\shell-task',
|
||||||
|
interpreter: 'cmd.exe',
|
||||||
|
script: 'run.bat',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('creates an HTTP action instead of forcing script type', () => {
|
it('creates an HTTP action instead of forcing script type', () => {
|
||||||
const values = createEmptyTaskFormValues()
|
const values = createEmptyTaskFormValues()
|
||||||
values.id = 'health_check'
|
values.id = 'health_check'
|
||||||
|
|||||||
@@ -12,6 +12,75 @@ export interface KeyValueFormValue {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScriptLanguage = 'python' | 'shell' | 'node' | 'lua'
|
||||||
|
|
||||||
|
export const SCRIPT_LANGUAGE_META: Record<
|
||||||
|
ScriptLanguage,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
defaultInterpreter: string
|
||||||
|
defaultScript: string
|
||||||
|
scriptLabel: string
|
||||||
|
scriptPlaceholder: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
python: {
|
||||||
|
label: 'Python',
|
||||||
|
defaultInterpreter: '',
|
||||||
|
defaultScript: 'main.py',
|
||||||
|
scriptLabel: '脚本文件或相对路径',
|
||||||
|
scriptPlaceholder: 'main.py 或 scripts\\sync.py',
|
||||||
|
hint: '优先复用项目 .venv;没有环境时可用 uv 从 pyproject.toml / requirements.txt 创建,运行时会注入虚拟环境变量。',
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
label: 'Shell (cmd)',
|
||||||
|
defaultInterpreter: 'cmd.exe',
|
||||||
|
defaultScript: 'run.bat',
|
||||||
|
scriptLabel: '批处理文件或相对路径',
|
||||||
|
scriptPlaceholder: 'run.bat 或 scripts\\start.cmd',
|
||||||
|
hint: '通过 cmd.exe /C 执行批处理文件,不需要虚拟环境。',
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
label: 'Node.js',
|
||||||
|
defaultInterpreter: 'node.exe',
|
||||||
|
defaultScript: 'index.js',
|
||||||
|
scriptLabel: 'JavaScript 文件或相对路径',
|
||||||
|
scriptPlaceholder: 'index.js 或 scripts\\worker.js',
|
||||||
|
hint: '通过 node.exe 执行脚本,不需要虚拟环境;需要本机 PATH 中已安装 Node.js。',
|
||||||
|
},
|
||||||
|
lua: {
|
||||||
|
label: 'Lua',
|
||||||
|
defaultInterpreter: 'lua.exe',
|
||||||
|
defaultScript: 'main.lua',
|
||||||
|
scriptLabel: 'Lua 文件或相对路径',
|
||||||
|
scriptPlaceholder: 'main.lua 或 scripts\\task.lua',
|
||||||
|
hint: '通过 lua.exe 执行脚本,不需要虚拟环境;需要本机 PATH 中已安装 Lua。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectScriptLanguage(interpreter: string): ScriptLanguage {
|
||||||
|
const name = interpreter
|
||||||
|
.trim()
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.toLowerCase()
|
||||||
|
.replace(/\.exe$/, '')
|
||||||
|
if (name === 'node' || name === 'nodejs') {
|
||||||
|
return 'node'
|
||||||
|
}
|
||||||
|
if (name?.startsWith('lua')) {
|
||||||
|
return 'lua'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
name &&
|
||||||
|
['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh'].includes(name)
|
||||||
|
) {
|
||||||
|
return 'shell'
|
||||||
|
}
|
||||||
|
return 'python'
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskFormValues {
|
export interface TaskFormValues {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -23,6 +92,7 @@ export interface TaskFormValues {
|
|||||||
script: {
|
script: {
|
||||||
working_dir: string
|
working_dir: string
|
||||||
venv: string
|
venv: string
|
||||||
|
language: ScriptLanguage
|
||||||
interpreter: string
|
interpreter: string
|
||||||
script: string
|
script: string
|
||||||
args: KeyValueFormValue[]
|
args: KeyValueFormValue[]
|
||||||
@@ -88,6 +158,7 @@ export const taskFormSchema = z
|
|||||||
script: z.object({
|
script: z.object({
|
||||||
working_dir: z.string(),
|
working_dir: z.string(),
|
||||||
venv: z.string(),
|
venv: z.string(),
|
||||||
|
language: z.enum(['python', 'shell', 'node', 'lua']),
|
||||||
interpreter: z.string(),
|
interpreter: z.string(),
|
||||||
script: z.string(),
|
script: z.string(),
|
||||||
args: z.array(keyValueSchema),
|
args: z.array(keyValueSchema),
|
||||||
@@ -164,6 +235,7 @@ export function createEmptyTaskFormValues(): TaskFormValues {
|
|||||||
script: {
|
script: {
|
||||||
working_dir: '',
|
working_dir: '',
|
||||||
venv: '',
|
venv: '',
|
||||||
|
language: 'python',
|
||||||
interpreter: '',
|
interpreter: '',
|
||||||
script: 'main.py',
|
script: 'main.py',
|
||||||
args: [],
|
args: [],
|
||||||
@@ -196,9 +268,11 @@ export function taskToFormValues(task: Task): TaskFormValues {
|
|||||||
values.actionType = task.action.type
|
values.actionType = task.action.type
|
||||||
|
|
||||||
if (task.action.type === 'script') {
|
if (task.action.type === 'script') {
|
||||||
|
const language = task.action.language ?? detectScriptLanguage(task.action.interpreter)
|
||||||
values.script = {
|
values.script = {
|
||||||
working_dir: task.action.working_dir,
|
working_dir: task.action.working_dir,
|
||||||
venv: task.action.venv ?? '',
|
venv: language === 'python' ? (task.action.venv ?? '') : '',
|
||||||
|
language,
|
||||||
interpreter: task.action.interpreter,
|
interpreter: task.action.interpreter,
|
||||||
script: task.action.script,
|
script: task.action.script,
|
||||||
args: argsToRows(task.action.args ?? []),
|
args: argsToRows(task.action.args ?? []),
|
||||||
@@ -238,6 +312,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction {
|
|||||||
type: 'script',
|
type: 'script',
|
||||||
working_dir: values.script.working_dir.trim(),
|
working_dir: values.script.working_dir.trim(),
|
||||||
venv: values.script.venv.trim() || null,
|
venv: values.script.venv.trim() || null,
|
||||||
|
language: values.script.language,
|
||||||
interpreter: values.script.interpreter.trim(),
|
interpreter: values.script.interpreter.trim(),
|
||||||
script: values.script.script.trim(),
|
script: values.script.script.trim(),
|
||||||
args: argumentsToArray(values.script.args),
|
args: argumentsToArray(values.script.args),
|
||||||
|
|||||||
Reference in New Issue
Block a user