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