From e5556100370e3a72d105593d4130e1beae063a5c Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 24 Oct 2026 23:20:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A1=B5=E9=9D=A2=E5=86=85=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + backend/Cargo.toml | 2 +- backend/src/api.rs | 39 ++- backend/src/directory_browser.rs | 238 ++++++++++++++++++ backend/src/lib.rs | 1 + backend/tests/api.rs | 84 +++++++ contracts/openapi.json | 91 +++++++ docs/decisions/0005-folder-picker.md | 33 +++ frontend/e2e/dashboard.spec.ts | 20 +- frontend/src/App.css | 120 +++++++++ frontend/src/App.test.tsx | 192 +++++++++++++- frontend/src/api/client.ts | 17 ++ frontend/src/api/generated.ts | 63 +++++ .../src/components/DirectoryPickerDialog.tsx | 208 +++++++++++++++ frontend/src/components/TaskEditorModal.tsx | 59 ++++- 15 files changed, 1154 insertions(+), 16 deletions(-) create mode 100644 backend/src/directory_browser.rs create mode 100644 docs/decisions/0005-folder-picker.md create mode 100644 frontend/src/components/DirectoryPickerDialog.tsx diff --git a/README.md b/README.md index e6fa8de..03051bc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标 - 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。 - Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。 - 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。 +- 工作目录支持「选择文件夹…」打开页面内的目录浏览器(盘符 → 逐级进入 → 使用此目录),选中后回填输入框,同时保留手动输入。 - 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。 - 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。 - 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。 @@ -171,6 +172,7 @@ $env:TASK_PILOT_LOG_ENCODING = "auto" | `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 | | `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 | | `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 | +| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 | | `GET` | `/api/v1/tasks` | 查询任务列表 | | `POST` | `/api/v1/tasks` | 创建任务 | | `GET` | `/api/v1/tasks/{id}` | 查询任务详情 | @@ -355,6 +357,7 @@ task_pilot/ - [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md) - [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md) - [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md) +- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md) ## 当前限制 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9f80bc1..165cfc7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -26,7 +26,7 @@ url = "2.5.8" utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] } utoipa-axum = "0.3.0" uuid = { version = "1.28.0", features = ["v4"] } -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/backend/src/api.rs b/backend/src/api.rs index 35fdcbf..5ffe169 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -13,7 +13,10 @@ use utoipa::{OpenApi, ToSchema}; use crate::{ cron::{self, CronPreviewRequest, CronPreviewResponse}, - error::{ApiError, ApiJson, ApiResult, ErrorResponse}, + directory_browser::{ + self, DirectoryEntry, DirectoryError, DirectoryListingResponse, ListDirectoriesQuery, + }, + error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse}, python_env::{ self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource, }, @@ -129,6 +132,36 @@ pub async fn probe_python_venv( .map_err(ApiError::internal) } +#[utoipa::path( + get, + path = "/api/v1/system/directories", + tag = "system", + params(ListDirectoriesQuery), + responses( + (status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse), + (status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse) + ) +)] +pub async fn list_directories( + ApiQuery(query): ApiQuery, +) -> ApiResult> { + tokio::task::spawn_blocking(move || directory_browser::list_directories(&query)) + .await + .map_err(ApiError::internal)? + .map(Json) + .map_err(|error| match error { + DirectoryError::RelativePath(message) => { + ApiError::bad_request("DIRECTORY_PATH_INVALID", message) + } + DirectoryError::NotFound(message) => { + ApiError::bad_request("DIRECTORY_NOT_FOUND", message) + } + DirectoryError::Unreadable(message) => { + ApiError::bad_request("DIRECTORY_UNREADABLE", message) + } + }) +} + #[derive(OpenApi)] #[openapi( info( @@ -141,6 +174,7 @@ pub async fn probe_python_venv( session, preview_cron, probe_python_venv, + list_directories, list_tasks, create_task, get_task, @@ -166,6 +200,8 @@ pub async fn probe_python_venv( VenvProbeStatus, VenvSource, InterpreterKind, + DirectoryEntry, + DirectoryListingResponse, ErrorResponse, TaskActionType, HttpMethod, @@ -215,6 +251,7 @@ pub fn router(state: AppState) -> Router { "/api/v1/python/venv", axum::routing::post(probe_python_venv), ) + .route("/api/v1/system/directories", get(list_directories)) .route("/api/v1/openapi.json", get(openapi_json)) .merge(crate::tasks::routes::router()) .merge(crate::runs::routes::router()) diff --git a/backend/src/directory_browser.rs b/backend/src/directory_browser.rs new file mode 100644 index 0000000..42aaf99 --- /dev/null +++ b/backend/src/directory_browser.rs @@ -0,0 +1,238 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +/// 单个目录最多返回的条目数,避免超大目录拖垮页面。 +pub const MAX_ENTRIES: usize = 500; + +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct ListDirectoriesQuery { + /// 绝对目录路径;留空表示列出盘符 + pub path: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DirectoryEntry { + /// 用于展示的名称 + pub name: String, + /// 绝对路径,选中后回填到表单 + pub path: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DirectoryListingResponse { + /// 当前目录;为 null 表示正在列出盘符 + pub path: Option, + /// 上一级目录;盘符根目录和盘符列表为 null + pub parent: Option, + pub entries: Vec, + /// 条目超过上限被截断时为 true + pub truncated: bool, +} + +#[derive(Debug)] +pub enum DirectoryError { + /// 传入的不是绝对路径 + RelativePath(String), + /// 路径不存在或不是目录 + NotFound(String), + /// 目录存在但无法读取(例如权限不足) + Unreadable(String), +} + +pub fn list_directories( + query: &ListDirectoriesQuery, +) -> Result { + let Some(requested) = query + .path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(DirectoryListingResponse { + path: None, + parent: None, + entries: logical_drives(), + truncated: false, + }); + }; + + let directory = PathBuf::from(requested); + if !directory.is_absolute() { + return Err(DirectoryError::RelativePath(format!( + "需要绝对路径: {requested}" + ))); + } + if !directory.is_dir() { + return Err(DirectoryError::NotFound(format!( + "目录不存在: {}", + directory.display() + ))); + } + + let reader = fs::read_dir(&directory).map_err(|error| { + DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display())) + })?; + + let mut entries = Vec::new(); + let mut truncated = false; + for entry in reader.flatten() { + let entry_path = entry.path(); + let is_directory = entry + .file_type() + .map(|kind| kind.is_dir()) + .unwrap_or_else(|_| entry_path.is_dir()); + if !is_directory { + continue; + } + entries.push(DirectoryEntry { + name: entry.file_name().to_string_lossy().into_owned(), + path: entry_path.to_string_lossy().into_owned(), + }); + if entries.len() >= MAX_ENTRIES { + truncated = true; + break; + } + } + entries.sort_by_key(|entry| entry.name.to_lowercase()); + + Ok(DirectoryListingResponse { + path: Some(directory.to_string_lossy().into_owned()), + parent: parent_of(&directory), + entries, + truncated, + }) +} + +fn parent_of(directory: &Path) -> Option { + directory + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.to_string_lossy().into_owned()) +} + +#[cfg(windows)] +fn logical_drives() -> Vec { + let mask = unsafe { windows_sys::Win32::Storage::FileSystem::GetLogicalDrives() }; + (0..26u32) + .filter(|index| mask & (1 << index) != 0) + .map(|index| { + let letter = char::from(b'A' + index as u8); + DirectoryEntry { + name: format!("{letter}:\\"), + path: format!("{letter}:\\"), + } + }) + .collect() +} + +#[cfg(not(windows))] +fn logical_drives() -> Vec { + vec![DirectoryEntry { + name: "/".to_owned(), + path: "/".to_owned(), + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(path: Option<&str>) -> ListDirectoriesQuery { + ListDirectoriesQuery { + path: path.map(str::to_owned), + } + } + + #[test] + fn lists_only_directories_sorted_by_name() { + let temp = tempfile::TempDir::new().unwrap(); + fs::create_dir(temp.path().join("zeta")).unwrap(); + fs::create_dir(temp.path().join("Alpha")).unwrap(); + fs::write(temp.path().join("file.txt"), "x").unwrap(); + + let listing = list_directories(&query(Some(temp.path().to_str().unwrap()))).unwrap(); + + let names = listing + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["Alpha", "zeta"]); + assert_eq!( + listing.parent.as_deref(), + Some(temp.path().parent().unwrap().to_str().unwrap()) + ); + assert!(!listing.truncated); + } + + #[test] + fn returns_absolute_paths_for_entries() { + let temp = tempfile::TempDir::new().unwrap(); + let nested = temp.path().join("nested"); + fs::create_dir(&nested).unwrap(); + + let listing = list_directories(&query(Some(temp.path().to_str().unwrap()))).unwrap(); + + assert_eq!(listing.entries[0].path, nested.to_string_lossy()); + assert_eq!(listing.path.as_deref(), Some(temp.path().to_str().unwrap())); + } + + #[test] + fn rejects_relative_paths() { + let error = list_directories(&query(Some("relative\\dir"))).unwrap_err(); + + assert!(matches!(error, DirectoryError::RelativePath(_))); + } + + #[test] + fn reports_missing_directories() { + let temp = tempfile::TempDir::new().unwrap(); + let missing = temp.path().join("does-not-exist"); + + let error = list_directories(&query(Some(missing.to_str().unwrap()))).unwrap_err(); + + assert!(matches!(error, DirectoryError::NotFound(_))); + } + + #[test] + fn treats_files_as_missing_directories() { + let temp = tempfile::TempDir::new().unwrap(); + let file = temp.path().join("file.txt"); + fs::write(&file, "x").unwrap(); + + let error = list_directories(&query(Some(file.to_str().unwrap()))).unwrap_err(); + + assert!(matches!(error, DirectoryError::NotFound(_))); + } + + #[test] + fn lists_logical_drives_when_path_is_empty() { + let listing = list_directories(&query(None)).unwrap(); + + assert!(listing.path.is_none()); + assert!(listing.parent.is_none()); + assert!(!listing.entries.is_empty()); + #[cfg(windows)] + assert!( + listing + .entries + .iter() + .all(|entry| entry.path.ends_with(":\\")) + ); + } + + #[test] + fn drive_roots_have_no_parent() { + let listing = list_directories(&query(logical_drives().first().map(|_| "C:\\"))); + // 某些机器没有 C 盘,此时跳过断言。 + if let Ok(listing) = listing { + assert!(listing.parent.is_none()); + } + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index e95f569..73fe15e 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,7 @@ pub mod api; pub mod cron; pub mod database; +pub mod directory_browser; pub mod error; pub mod process_job; pub mod python_env; diff --git a/backend/tests/api.rs b/backend/tests/api.rs index 71c7aef..bc4173c 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -1,3 +1,5 @@ +use std::fs; + use axum::{ body::Body, http::{Request, StatusCode}, @@ -71,4 +73,86 @@ async fn openapi_endpoint_describes_health_api() { assert_eq!(json["info"]["title"], "TaskPilot API"); assert!(json["paths"]["/api/v1/health"]["get"].is_object()); + assert!(json["paths"]["/api/v1/system/directories"]["get"].is_object()); +} + +fn encode_query_path(path: &std::path::Path) -> String { + path.to_string_lossy() + .replace('%', "%25") + .replace(':', "%3A") + .replace('\\', "%5C") +} + +async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, Value) { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("response body should be readable") + .to_bytes(); + + (status, serde_json::from_slice(&body).unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn directory_listing_endpoint_returns_subdirectories() { + let (app, temp_dir) = test_app().await; + let project = temp_dir.path().join("project"); + fs::create_dir_all(project.join("nested")).expect("nested directory should be created"); + fs::write(project.join("file.txt"), "x").expect("file should be written"); + + let uri = format!( + "/api/v1/system/directories?path={}", + encode_query_path(&project) + ); + let (status, body) = get_json(&app, &uri).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["path"], project.to_string_lossy().into_owned()); + let entries = body["entries"] + .as_array() + .expect("entries should be an array"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], "nested"); + assert_eq!( + entries[0]["path"], + project.join("nested").to_string_lossy().into_owned() + ); +} + +#[tokio::test] +async fn directory_listing_endpoint_rejects_relative_paths() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = get_json(&app, "/api/v1/system/directories?path=relative").await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "DIRECTORY_PATH_INVALID"); +} + +#[tokio::test] +async fn directory_listing_endpoint_returns_drives_without_path() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = get_json(&app, "/api/v1/system/directories").await; + + assert_eq!(status, StatusCode::OK); + assert!(body["path"].is_null()); + assert!( + !body["entries"] + .as_array() + .expect("entries should be an array") + .is_empty() + ); } diff --git a/contracts/openapi.json b/contracts/openapi.json index 75d3b57..81b2a53 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -337,6 +337,47 @@ } } }, + "/api/v1/system/directories": { + "get": { + "tags": [ + "system" + ], + "operationId": "list_directories", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "绝对目录路径;留空表示列出盘符", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Subdirectories of the requested path, or the drive list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryListingResponse" + } + } + } + }, + "400": { + "description": "The path is not an existing absolute directory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/tasks": { "get": { "tags": [ @@ -872,6 +913,56 @@ } } }, + "DirectoryEntry": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string", + "description": "用于展示的名称" + }, + "path": { + "type": "string", + "description": "绝对路径,选中后回填到表单" + } + } + }, + "DirectoryListingResponse": { + "type": "object", + "required": [ + "entries", + "truncated" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectoryEntry" + } + }, + "parent": { + "type": [ + "string", + "null" + ], + "description": "上一级目录;盘符根目录和盘符列表为 null" + }, + "path": { + "type": [ + "string", + "null" + ], + "description": "当前目录;为 null 表示正在列出盘符" + }, + "truncated": { + "type": "boolean", + "description": "条目超过上限被截断时为 true" + } + } + }, "ErrorResponse": { "type": "object", "required": [ diff --git a/docs/decisions/0005-folder-picker.md b/docs/decisions/0005-folder-picker.md new file mode 100644 index 0000000..0dd72a4 --- /dev/null +++ b/docs/decisions/0005-folder-picker.md @@ -0,0 +1,33 @@ +# ADR-0005:页面内目录浏览器 + +- 状态:已接受(取代早期"后端弹出系统对话框"方案) +- 日期:2026-10-24 + +## 背景 + +任务的工作目录必须是绝对 Windows 路径。浏览器的文件选择控件出于安全限制只暴露文件名或伪造路径,拿不到用户所选目录的真实绝对路径。 + +最初尝试由后端调用 `IFileOpenDialog` 弹出系统文件夹对话框,但代价明显:需要新增 `windows` crate;对话框属于后端进程而不属于浏览器窗口,可能被浏览器挡住;接口会长时间挂起;自动化测试无法覆盖。对于一个本机 Web 应用,这些都不划算。 + +## 决策 + +1. 在页面内实现目录浏览器:`DirectoryPickerDialog` 弹窗,盘符 → 逐级进入 → 「使用此目录」。 +2. 后端只提供只读列表接口 `GET /api/v1/system/directories?path=<绝对路径>`,路径为空时返回盘符。 +3. 响应包含 `path`(当前目录)、`parent`(上一级,盘符根为 null)、`entries`(仅子文件夹,按名称排序)、`truncated`(超过 500 项)。 +4. 弹窗顶部保留可编辑的路径输入框,支持直接粘贴绝对路径后回车跳转;「上一级」「盘符」用于层级导航。 +5. 选中结果通过 `setValue` 回填输入框并触发虚拟环境重新探测;输入框始终可编辑,二者不互斥。 +6. 列表接口是只读的,与任务列表一样免令牌;不存在的路径返回 `400 DIRECTORY_NOT_FOUND`,相对路径返回 `400 DIRECTORY_PATH_INVALID`,无权限返回 `400 DIRECTORY_UNREADABLE`。 + +## 原因 + +- 纯前端就能完成交互,不依赖桌面会话,Windows 服务模式下同样可用。 +- 不引入新依赖,也不需要 COM 线程管理。 +- 弹窗属于页面本身,不会被浏览器遮挡,焦点和键盘行为可控。 +- 可以被 Vitest 和 Playwright 完整覆盖。 + +## 边界 + +- 只列文件夹,不列文件;隐藏目录(如 `.venv`)会正常显示,便于选择虚拟环境目录。 +- 目录读取失败时停留在原目录并提示原因,不做静默降级。 +- 单个目录最多返回 500 个子文件夹,超出时提示截断。 +- 列表接口只做只读列举;真正执行脚本时仍需任务配置中的绝对路径通过后端校验。 diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 09356f3..6ad661b 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -73,7 +73,25 @@ test('manages a task and reads a real run log', async ({ page, request }) => { }) const workingDir = createVenvFixture() - await newTaskDialog.getByLabel(/项目工作目录/).fill(workingDir) + const workingDirInput = newTaskDialog.getByLabel(/项目工作目录/) + // Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。 + await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible() + await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click() + const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' }) + await expect(directoryPicker).toBeVisible() + const directoryPathInput = directoryPicker.getByLabel('目录路径') + await directoryPathInput.fill(workingDir) + await directoryPathInput.press('Enter') + await expect( + directoryPicker.getByRole('button', { name: '使用此目录' }), + ).toBeEnabled() + await page.screenshot({ + path: 'test-results/dashboard-directory-picker.png', + fullPage: true, + }) + await directoryPicker.getByRole('button', { name: '使用此目录' }).click() + await expect(directoryPicker).toBeHidden() + await expect(workingDirInput).toHaveValue(workingDir) await expect( newTaskDialog.locator('.env-status-badge.ok code'), ).toHaveText('.venv\\Scripts\\python.exe') diff --git a/frontend/src/App.css b/frontend/src/App.css index 45f2813..5ee7afa 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -767,6 +767,24 @@ code { color: var(--danger); } +.field-with-action { + display: flex; + align-items: center; + gap: 8px; +} + +.field-with-action .field-input { + min-width: 0; + flex: 1; +} + +.pick-directory-button { + flex-shrink: 0; + min-height: 38px; + padding: 8px 14px; + white-space: nowrap; +} + .env-detector-bar { display: flex; flex-wrap: wrap; @@ -986,6 +1004,108 @@ code { margin-top: 24px; } +.directory-picker { + display: flex; + width: min(760px, 100%); + flex-direction: column; +} + +.directory-picker-toolbar { + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + padding: 14px 24px; +} + +.directory-path-form { + display: flex; + min-width: 0; + flex: 1; +} + +.directory-picker-body { + max-height: 46vh; + min-height: 220px; + flex: 1; + overflow-y: auto; + padding: 12px 16px 16px; +} + +.directory-list { + display: flex; + margin: 0; + padding: 0; + flex-direction: column; + gap: 2px; + list-style: none; +} + +.directory-row { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + border: 0; + border-radius: 8px; + padding: 8px 10px; + background: transparent; + color: var(--text); + cursor: pointer; + font-size: 13px; + text-align: left; +} + +.directory-row:hover { + background: var(--surface-muted); +} + +.directory-row:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.directory-icon { + flex-shrink: 0; + font-size: 13px; +} + +.directory-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.directory-picker-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--border); + padding: 14px 24px; + background: #fafafb; +} + +.directory-picker-summary { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.directory-picker-summary code { + overflow: hidden; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.directory-picker-actions { + margin-top: 0; +} + .drawer-overlay { align-items: center; justify-content: center; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1797b24..6a64e6b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' @@ -44,7 +44,11 @@ function jsonResponse(body: unknown, status = 200): Response { }) } -function createFetchMock() { +interface FetchMockOptions { + directories?: (path: string | null) => Response +} + +function createFetchMock(options: FetchMockOptions = {}) { return vi.fn( async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = @@ -89,6 +93,19 @@ function createFetchMock() { : '未检测到虚拟环境,将直接使用系统解释器', }) } + if (url.includes('/api/v1/system/directories')) { + const requestedPath = new URL(url, 'http://127.0.0.1').searchParams.get( + 'path', + ) + return options.directories + ? options.directories(requestedPath) + : jsonResponse({ + path: null, + parent: null, + entries: [], + truncated: false, + }) + } if (url.includes('/api/v1/tasks') && init?.method === 'POST') { return jsonResponse( { @@ -212,4 +229,175 @@ describe('TaskDashboard', () => { screen.getByText('未检测到虚拟环境,将直接使用系统解释器'), ).toBeInTheDocument() }) + + it('browses folders in the page and fills the working directory', async () => { + const listings: Record = { + '': { + path: null, + parent: null, + truncated: false, + entries: [{ name: 'D:\\', path: 'D:\\' }], + }, + 'D:\\': { + path: 'D:\\', + parent: null, + truncated: false, + entries: [{ name: 'Projects', path: 'D:\\Projects' }], + }, + 'D:\\Projects': { + path: 'D:\\Projects', + parent: 'D:\\', + truncated: false, + entries: [{ name: 'sync', path: 'D:\\Projects\\sync' }], + }, + 'D:\\Projects\\sync': { + path: 'D:\\Projects\\sync', + parent: 'D:\\Projects', + truncated: false, + entries: [], + }, + } + vi.stubGlobal( + 'fetch', + createFetchMock({ + directories: (path) => + jsonResponse( + listings[path ?? ''] ?? { + code: 'DIRECTORY_NOT_FOUND', + message: '目录不存在', + details: {}, + }, + ), + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.click(screen.getByRole('button', { name: '选择文件夹…' })) + + const dialog = await screen.findByRole('dialog', { + name: '选择项目工作目录', + }) + await user.click(await within(dialog).findByRole('button', { name: 'D:\\' })) + await user.click(await within(dialog).findByRole('button', { name: 'Projects' })) + await user.click(await within(dialog).findByRole('button', { name: 'sync' })) + expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: '使用此目录' })) + + expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull() + expect(screen.getByLabelText(/项目工作目录/)).toHaveValue( + 'D:\\Projects\\sync', + ) + }) + + it('navigates to the parent folder and back to the drive list', async () => { + const listings: Record = { + '': { + path: null, + parent: null, + truncated: false, + entries: [{ name: 'D:\\', path: 'D:\\' }], + }, + 'D:\\': { + path: 'D:\\', + parent: null, + truncated: false, + entries: [{ name: 'Projects', path: 'D:\\Projects' }], + }, + 'D:\\Projects': { + path: 'D:\\Projects', + parent: 'D:\\', + truncated: false, + entries: [], + }, + } + vi.stubGlobal( + 'fetch', + createFetchMock({ + directories: (path) => jsonResponse(listings[path ?? '']), + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.click(screen.getByRole('button', { name: '选择文件夹…' })) + const dialog = await screen.findByRole('dialog', { + name: '选择项目工作目录', + }) + await user.click(await within(dialog).findByRole('button', { name: 'D:\\' })) + await user.click(await within(dialog).findByRole('button', { name: 'Projects' })) + expect( + await within(dialog).findByText('该目录下没有子文件夹'), + ).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: '上一级' })) + expect( + await within(dialog).findByRole('button', { name: 'Projects' }), + ).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: '盘符' })) + expect( + await within(dialog).findByRole('button', { name: 'D:\\' }), + ).toBeInTheDocument() + }) + + it('keeps the typed path when the directory picker is cancelled', async () => { + vi.stubGlobal( + 'fetch', + createFetchMock({ + directories: () => + jsonResponse({ path: null, parent: null, entries: [], truncated: false }), + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + const input = screen.getByLabelText(/项目工作目录/) + await user.type(input, 'D:\\Manual\\path') + await user.click(screen.getByRole('button', { name: '选择文件夹…' })) + + const dialog = await screen.findByRole('dialog', { + name: '选择项目工作目录', + }) + await user.click(within(dialog).getByRole('button', { name: '取消' })) + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(), + ) + expect(input).toHaveValue('D:\\Manual\\path') + }) + + it('shows the listing error inside the directory picker', async () => { + vi.stubGlobal( + 'fetch', + createFetchMock({ + directories: () => + jsonResponse( + { + code: 'DIRECTORY_NOT_FOUND', + message: '目录不存在: Z:\\nope', + details: {}, + }, + 400, + ), + }), + ) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.type(screen.getByLabelText(/项目工作目录/), 'Z:\\nope') + await user.click(screen.getByRole('button', { name: '选择文件夹…' })) + + const dialog = await screen.findByRole('dialog', { + name: '选择项目工作目录', + }) + expect( + await within(dialog).findByText('目录不存在: Z:\\nope'), + ).toBeInTheDocument() + expect( + within(dialog).getByRole('button', { name: '使用此目录' }), + ).toBeDisabled() + }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aec58fd..6dad335 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,9 @@ export type VenvProbeResponse = components['schemas']['VenvProbeResponse'] export type VenvProbeStatus = components['schemas']['VenvProbeStatus'] export type VenvSource = components['schemas']['VenvSource'] export type InterpreterKind = components['schemas']['InterpreterKind'] +export type DirectoryEntry = components['schemas']['DirectoryEntry'] +export type DirectoryListingResponse = + components['schemas']['DirectoryListingResponse'] export interface TaskFilters { q?: string @@ -248,3 +251,17 @@ export function probePythonVenv( signal, }) } + +/** + * 列出目录下的子文件夹;不传 path 时返回盘符列表。 + * 用于页面内的目录浏览器,选中后把绝对路径回填到表单。 + */ +export function listDirectories( + path?: string, + signal?: AbortSignal, +): Promise { + return requestJson( + withQuery('/api/v1/system/directories', { path }), + { signal }, + ) +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 8b36fec..4db9801 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -148,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/system/directories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_directories"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tasks": { parameters: { query?: never; @@ -271,6 +287,21 @@ export interface components { runs: string[]; timezone: string; }; + DirectoryEntry: { + /** @description 用于展示的名称 */ + name: string; + /** @description 绝对路径,选中后回填到表单 */ + path: string; + }; + DirectoryListingResponse: { + entries: components["schemas"]["DirectoryEntry"][]; + /** @description 上一级目录;盘符根目录和盘符列表为 null */ + parent?: string | null; + /** @description 当前目录;为 null 表示正在列出盘符 */ + path?: string | null; + /** @description 条目超过上限被截断时为 true */ + truncated: boolean; + }; ErrorResponse: { code: string; details: { @@ -707,6 +738,38 @@ export interface operations { }; }; }; + list_directories: { + parameters: { + query?: { + /** @description 绝对目录路径;留空表示列出盘符 */ + path?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Subdirectories of the requested path, or the drive list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DirectoryListingResponse"]; + }; + }; + /** @description The path is not an existing absolute directory */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; list_tasks: { parameters: { query?: { diff --git a/frontend/src/components/DirectoryPickerDialog.tsx b/frontend/src/components/DirectoryPickerDialog.tsx new file mode 100644 index 0000000..171a104 --- /dev/null +++ b/frontend/src/components/DirectoryPickerDialog.tsx @@ -0,0 +1,208 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' +import { listDirectories } from '../api/client' +import { useFocusTrap } from '../lib/useFocusTrap' + +interface DirectoryPickerDialogProps { + open: boolean + initialPath: string + onSelect: (path: string) => void + onClose: () => void +} + +/** + * 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。 + * 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。 + */ +export function DirectoryPickerDialog({ + open, + initialPath, + onSelect, + onClose, +}: DirectoryPickerDialogProps) { + const [currentPath, setCurrentPath] = useState(null) + const [draftPath, setDraftPath] = useState('') + const dialogRef = useRef(null) + useFocusTrap(open, dialogRef, '[data-picker-path]') + + useEffect(() => { + if (!open) { + return + } + const start = initialPath.trim() + setCurrentPath(start || null) + setDraftPath(start) + }, [initialPath, open]) + + const listing = useQuery({ + queryKey: ['directories', currentPath], + queryFn: ({ signal }) => listDirectories(currentPath ?? undefined, signal), + enabled: open, + retry: false, + }) + + useEffect(() => { + if (listing.data?.path) { + setDraftPath(listing.data.path) + } + }, [listing.data?.path]) + + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose, open]) + + if (!open) { + return null + } + + const entries = listing.data?.entries ?? [] + const currentDirectory = listing.data?.path ?? null + const parent = listing.data?.parent ?? null + + const navigate = (path: string | null) => { + setCurrentPath(path) + setDraftPath(path ?? '') + } + + return ( +
{ + if (event.currentTarget === event.target) { + onClose() + } + }} + > +
+
+
+

WORKING DIRECTORY

+

选择项目工作目录

+
+ +
+ +
+ + +
{ + event.preventDefault() + navigate(draftPath.trim() || null) + }} + > + setDraftPath(event.target.value)} + placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync" + aria-label="目录路径" + data-picker-path + spellCheck={false} + /> +
+
+ +
+ {listing.isPending ? ( +

正在读取目录…

+ ) : null} + {listing.isError ? ( +
+ {listing.error.message} +
+ ) : null} + {!listing.isPending && !listing.isError && entries.length === 0 ? ( +

+ {currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'} +

+ ) : null} + {entries.length > 0 ? ( +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ ) : null} + {listing.data?.truncated ? ( +

文件夹过多,仅列出前 500 个。

+ ) : null} +
+ +
+
+ 将使用 + {currentDirectory ?? '尚未选择目录'} +
+
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index a44ed73..f42e2a2 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -14,6 +14,7 @@ import { type Task, type VenvProbeResponse, } from '../api/client' +import { DirectoryPickerDialog } from './DirectoryPickerDialog' import { createEmptyTaskFormValues, mapServerFieldToFormField, @@ -42,8 +43,10 @@ export function TaskEditorModal({ }: TaskEditorModalProps) { const [submitError, setSubmitError] = useState(null) const [cronPreviewCount, setCronPreviewCount] = useState(5) + const [pickerOpen, setPickerOpen] = useState(false) const dialogRef = useRef(null) - useFocusTrap(open, dialogRef, '[data-dialog-autofocus]') + // 目录选择弹窗打开时让出焦点管理,避免两个焦点陷阱互相争夺 Tab。 + useFocusTrap(open && !pickerOpen, dialogRef, '[data-dialog-autofocus]') const { register, control, @@ -51,6 +54,7 @@ export function TaskEditorModal({ reset, watch, setError, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(taskFormSchema), @@ -119,6 +123,7 @@ export function TaskEditorModal({ const previewVirtualEnv = scriptPreview.venv.trim() || linkedVenv?.venv_path || '' const previewInterpreter = linkedVenv?.display_path ?? null + const scriptArgs = useFieldArray({ control, name: 'script.args' }) const scriptEnv = useFieldArray({ control, name: 'script.env' }) const httpHeaders = useFieldArray({ control, name: 'http.headers' }) @@ -132,6 +137,7 @@ export function TaskEditorModal({ reset(task ? taskToFormValues(task) : createEmptyTaskFormValues()) setCronPreviewCount(5) setSubmitError(null) + setPickerOpen(false) }, [open, reset, task]) useEffect(() => { @@ -346,16 +352,35 @@ export function TaskEditorModal({ {actionType === 'script' ? (
- - - +
+ +
+ + +
+ {errors.script?.working_dir?.message ? ( + + {errors.script.working_dir.message} + + ) : null} +
+ + { + setValue('script.working_dir', path, { + shouldDirty: true, + shouldValidate: true, + }) + }} + onClose={() => setPickerOpen(false)} + />
) }