feat: 工作目录支持页面内目录浏览器
This commit is contained in:
@@ -32,6 +32,7 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标
|
|||||||
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
||||||
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
||||||
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
|
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
|
||||||
|
- 工作目录支持「选择文件夹…」打开页面内的目录浏览器(盘符 → 逐级进入 → 使用此目录),选中后回填输入框,同时保留手动输入。
|
||||||
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
||||||
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
|
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
|
||||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||||
@@ -171,6 +172,7 @@ $env:TASK_PILOT_LOG_ENCODING = "auto"
|
|||||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
||||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||||
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
||||||
|
| `GET` | `/api/v1/system/directories` | 列出盘符或指定目录下的子文件夹 |
|
||||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
||||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
||||||
@@ -355,6 +357,7 @@ task_pilot/
|
|||||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||||
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
||||||
|
- [ADR-0005:由后端提供系统文件夹选择](docs/decisions/0005-folder-picker.md)
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ url = "2.5.8"
|
|||||||
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||||
utoipa-axum = "0.3.0"
|
utoipa-axum = "0.3.0"
|
||||||
uuid = { version = "1.28.0", features = ["v4"] }
|
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]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
|
|||||||
+38
-1
@@ -13,7 +13,10 @@ use utoipa::{OpenApi, ToSchema};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
||||||
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
|
directory_browser::{
|
||||||
|
self, DirectoryEntry, DirectoryError, DirectoryListingResponse, ListDirectoriesQuery,
|
||||||
|
},
|
||||||
|
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
|
||||||
python_env::{
|
python_env::{
|
||||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||||
},
|
},
|
||||||
@@ -129,6 +132,36 @@ pub async fn probe_python_venv(
|
|||||||
.map_err(ApiError::internal)
|
.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<ListDirectoriesQuery>,
|
||||||
|
) -> ApiResult<Json<DirectoryListingResponse>> {
|
||||||
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
info(
|
info(
|
||||||
@@ -141,6 +174,7 @@ pub async fn probe_python_venv(
|
|||||||
session,
|
session,
|
||||||
preview_cron,
|
preview_cron,
|
||||||
probe_python_venv,
|
probe_python_venv,
|
||||||
|
list_directories,
|
||||||
list_tasks,
|
list_tasks,
|
||||||
create_task,
|
create_task,
|
||||||
get_task,
|
get_task,
|
||||||
@@ -166,6 +200,8 @@ pub async fn probe_python_venv(
|
|||||||
VenvProbeStatus,
|
VenvProbeStatus,
|
||||||
VenvSource,
|
VenvSource,
|
||||||
InterpreterKind,
|
InterpreterKind,
|
||||||
|
DirectoryEntry,
|
||||||
|
DirectoryListingResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
TaskActionType,
|
TaskActionType,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
@@ -215,6 +251,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/v1/python/venv",
|
"/api/v1/python/venv",
|
||||||
axum::routing::post(probe_python_venv),
|
axum::routing::post(probe_python_venv),
|
||||||
)
|
)
|
||||||
|
.route("/api/v1/system/directories", get(list_directories))
|
||||||
.route("/api/v1/openapi.json", get(openapi_json))
|
.route("/api/v1/openapi.json", get(openapi_json))
|
||||||
.merge(crate::tasks::routes::router())
|
.merge(crate::tasks::routes::router())
|
||||||
.merge(crate::runs::routes::router())
|
.merge(crate::runs::routes::router())
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DirectoryEntry {
|
||||||
|
/// 用于展示的名称
|
||||||
|
pub name: String,
|
||||||
|
/// 绝对路径,选中后回填到表单
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DirectoryListingResponse {
|
||||||
|
/// 当前目录;为 null 表示正在列出盘符
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// 上一级目录;盘符根目录和盘符列表为 null
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub entries: Vec<DirectoryEntry>,
|
||||||
|
/// 条目超过上限被截断时为 true
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DirectoryError {
|
||||||
|
/// 传入的不是绝对路径
|
||||||
|
RelativePath(String),
|
||||||
|
/// 路径不存在或不是目录
|
||||||
|
NotFound(String),
|
||||||
|
/// 目录存在但无法读取(例如权限不足)
|
||||||
|
Unreadable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_directories(
|
||||||
|
query: &ListDirectoriesQuery,
|
||||||
|
) -> Result<DirectoryListingResponse, DirectoryError> {
|
||||||
|
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<String> {
|
||||||
|
directory
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
.map(|parent| parent.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn logical_drives() -> Vec<DirectoryEntry> {
|
||||||
|
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<DirectoryEntry> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
pub mod directory_browser;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod process_job;
|
pub mod process_job;
|
||||||
pub mod python_env;
|
pub mod python_env;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
@@ -71,4 +73,86 @@ async fn openapi_endpoint_describes_health_api() {
|
|||||||
|
|
||||||
assert_eq!(json["info"]["title"], "TaskPilot API");
|
assert_eq!(json["info"]["title"], "TaskPilot API");
|
||||||
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
|
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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/tasks": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"ErrorResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -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 个子文件夹,超出时提示截断。
|
||||||
|
- 列表接口只做只读列举;真正执行脚本时仍需任务配置中的绝对路径通过后端校验。
|
||||||
@@ -73,7 +73,25 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const workingDir = createVenvFixture()
|
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(
|
await expect(
|
||||||
newTaskDialog.locator('.env-status-badge.ok code'),
|
newTaskDialog.locator('.env-status-badge.ok code'),
|
||||||
).toHaveText('.venv\\Scripts\\python.exe')
|
).toHaveText('.venv\\Scripts\\python.exe')
|
||||||
|
|||||||
@@ -767,6 +767,24 @@ code {
|
|||||||
color: var(--danger);
|
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 {
|
.env-detector-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -986,6 +1004,108 @@ code {
|
|||||||
margin-top: 24px;
|
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 {
|
.drawer-overlay {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
+190
-2
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
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 userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
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(
|
return vi.fn(
|
||||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const url =
|
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') {
|
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
@@ -212,4 +229,175 @@ describe('TaskDashboard', () => {
|
|||||||
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
screen.getByText('未检测到虚拟环境,将直接使用系统解释器'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('browses folders in the page and fills the working directory', async () => {
|
||||||
|
const listings: Record<string, unknown> = {
|
||||||
|
'': {
|
||||||
|
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<string, unknown> = {
|
||||||
|
'': {
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type VenvProbeResponse = components['schemas']['VenvProbeResponse']
|
|||||||
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
export type VenvProbeStatus = components['schemas']['VenvProbeStatus']
|
||||||
export type VenvSource = components['schemas']['VenvSource']
|
export type VenvSource = components['schemas']['VenvSource']
|
||||||
export type InterpreterKind = components['schemas']['InterpreterKind']
|
export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||||
|
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||||||
|
export type DirectoryListingResponse =
|
||||||
|
components['schemas']['DirectoryListingResponse']
|
||||||
|
|
||||||
export interface TaskFilters {
|
export interface TaskFilters {
|
||||||
q?: string
|
q?: string
|
||||||
@@ -248,3 +251,17 @@ export function probePythonVenv(
|
|||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出目录下的子文件夹;不传 path 时返回盘符列表。
|
||||||
|
* 用于页面内的目录浏览器,选中后把绝对路径回填到表单。
|
||||||
|
*/
|
||||||
|
export function listDirectories(
|
||||||
|
path?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DirectoryListingResponse> {
|
||||||
|
return requestJson<DirectoryListingResponse>(
|
||||||
|
withQuery('/api/v1/system/directories', { path }),
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/tasks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -271,6 +287,21 @@ export interface components {
|
|||||||
runs: string[];
|
runs: string[];
|
||||||
timezone: 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: {
|
ErrorResponse: {
|
||||||
code: string;
|
code: string;
|
||||||
details: {
|
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: {
|
list_tasks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -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<string | null>(null)
|
||||||
|
const [draftPath, setDraftPath] = useState('')
|
||||||
|
const dialogRef = useRef<HTMLElement>(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 (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.currentTarget === event.target) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={dialogRef}
|
||||||
|
className="modal-card directory-picker"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="directory-picker-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<header className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">WORKING DIRECTORY</p>
|
||||||
|
<h2 id="directory-picker-title">选择项目工作目录</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭目录选择"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="directory-picker-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-secondary button-small"
|
||||||
|
onClick={() => navigate(parent)}
|
||||||
|
disabled={!parent}
|
||||||
|
>
|
||||||
|
上一级
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-secondary button-small"
|
||||||
|
onClick={() => navigate(null)}
|
||||||
|
>
|
||||||
|
盘符
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
className="directory-path-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate(draftPath.trim() || null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="field-input mono"
|
||||||
|
value={draftPath}
|
||||||
|
onChange={(event) => setDraftPath(event.target.value)}
|
||||||
|
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||||
|
aria-label="目录路径"
|
||||||
|
data-picker-path
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="directory-picker-body">
|
||||||
|
{listing.isPending ? (
|
||||||
|
<p className="muted-copy">正在读取目录…</p>
|
||||||
|
) : null}
|
||||||
|
{listing.isError ? (
|
||||||
|
<div className="inline-alert inline-alert-error" role="alert">
|
||||||
|
{listing.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||||
|
<p className="empty-inline">
|
||||||
|
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<ul className="directory-list">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li key={entry.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="directory-row"
|
||||||
|
onClick={() => navigate(entry.path)}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<span className="directory-icon" aria-hidden="true">
|
||||||
|
📁
|
||||||
|
</span>
|
||||||
|
<span className="directory-name">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{listing.data?.truncated ? (
|
||||||
|
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="directory-picker-footer">
|
||||||
|
<div className="directory-picker-summary">
|
||||||
|
<span className="section-label">将使用</span>
|
||||||
|
<code>{currentDirectory ?? '尚未选择目录'}</code>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions directory-picker-actions">
|
||||||
|
<button type="button" className="button button-secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-primary"
|
||||||
|
disabled={!currentDirectory}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentDirectory) {
|
||||||
|
onSelect(currentDirectory)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
使用此目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type Task,
|
type Task,
|
||||||
type VenvProbeResponse,
|
type VenvProbeResponse,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
|
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
||||||
import {
|
import {
|
||||||
createEmptyTaskFormValues,
|
createEmptyTaskFormValues,
|
||||||
mapServerFieldToFormField,
|
mapServerFieldToFormField,
|
||||||
@@ -42,8 +43,10 @@ export function TaskEditorModal({
|
|||||||
}: TaskEditorModalProps) {
|
}: TaskEditorModalProps) {
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const dialogRef = useRef<HTMLElement>(null)
|
const dialogRef = useRef<HTMLElement>(null)
|
||||||
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
|
// 目录选择弹窗打开时让出焦点管理,避免两个焦点陷阱互相争夺 Tab。
|
||||||
|
useFocusTrap(open && !pickerOpen, dialogRef, '[data-dialog-autofocus]')
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
@@ -51,6 +54,7 @@ export function TaskEditorModal({
|
|||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
setError,
|
setError,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<TaskFormValues>({
|
} = useForm<TaskFormValues>({
|
||||||
resolver: zodResolver(taskFormSchema),
|
resolver: zodResolver(taskFormSchema),
|
||||||
@@ -119,6 +123,7 @@ export function TaskEditorModal({
|
|||||||
const previewVirtualEnv =
|
const previewVirtualEnv =
|
||||||
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
scriptPreview.venv.trim() || linkedVenv?.venv_path || ''
|
||||||
const previewInterpreter = linkedVenv?.display_path ?? null
|
const previewInterpreter = linkedVenv?.display_path ?? 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' })
|
||||||
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
||||||
@@ -132,6 +137,7 @@ export function TaskEditorModal({
|
|||||||
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
|
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
|
||||||
setCronPreviewCount(5)
|
setCronPreviewCount(5)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
|
setPickerOpen(false)
|
||||||
}, [open, reset, task])
|
}, [open, reset, task])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -346,16 +352,35 @@ export function TaskEditorModal({
|
|||||||
|
|
||||||
{actionType === 'script' ? (
|
{actionType === 'script' ? (
|
||||||
<div className="dynamic-fields">
|
<div className="dynamic-fields">
|
||||||
<Field
|
<div className="field">
|
||||||
label="项目工作目录 (Working Directory)"
|
<label className="field-label" htmlFor="task-working-dir">
|
||||||
error={errors.script?.working_dir?.message}
|
项目工作目录 (Working Directory)
|
||||||
>
|
</label>
|
||||||
<input
|
<div className="field-with-action">
|
||||||
{...register('script.working_dir')}
|
<input
|
||||||
className="field-input mono"
|
id="task-working-dir"
|
||||||
placeholder="D:\Projects\sync"
|
{...register('script.working_dir')}
|
||||||
/>
|
className="field-input mono"
|
||||||
</Field>
|
placeholder="D:\Projects\sync"
|
||||||
|
aria-invalid={Boolean(errors.script?.working_dir)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-secondary pick-directory-button"
|
||||||
|
// 不夺走输入框焦点:否则 blur 校验插入错误行会让表单瞬间下移,
|
||||||
|
// 点击可能落到移位后的其它元素上。
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
选择文件夹…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.script?.working_dir?.message ? (
|
||||||
|
<span className="field-error">
|
||||||
|
{errors.script.working_dir.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<VenvDetectorBar
|
<VenvDetectorBar
|
||||||
workingDir={debouncedWorkingDir}
|
workingDir={debouncedWorkingDir}
|
||||||
probe={venvProbe.data}
|
probe={venvProbe.data}
|
||||||
@@ -643,6 +668,18 @@ export function TaskEditorModal({
|
|||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DirectoryPickerDialog
|
||||||
|
open={pickerOpen}
|
||||||
|
initialPath={scriptPreview.working_dir}
|
||||||
|
onSelect={(path) => {
|
||||||
|
setValue('script.working_dir', path, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user