diff --git a/crates/df-storage/src/crud/project_module_repo.rs b/crates/df-storage/src/crud/project_module_repo.rs index 351f04f..1e50c98 100644 --- a/crates/df-storage/src/crud/project_module_repo.rs +++ b/crates/df-storage/src/crud/project_module_repo.rs @@ -35,6 +35,8 @@ fn project_module_from_row(row: &Row<'_>) -> std::result::Result, + /// 工程职责描述(如"前端 web 工程"),可选。V40 加。 + #[serde(default)] + pub description: Option, + /// 工程状态 active/archived,可选(默认 active)。V40 加。 + #[serde(default)] + pub status: Option, } /// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。 @@ -57,6 +65,12 @@ pub struct UpdateProjectModuleInput { pub git_url: Option, #[serde(default)] pub stack: Option, + /// 工程职责描述,V40 加。空串归一为 None(trim_opt)。 + #[serde(default)] + pub description: Option, + /// 工程状态 active/archived,V40 加。 + #[serde(default)] + pub status: Option, } // ============================================================ @@ -67,6 +81,32 @@ fn trim_opt(s: Option) -> Option { s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) } +/// 路径穿越防御:分段匹配 `..` 段(split '/' 或 '\' 后 any 段 == "..")。 +/// +/// 与裸 `contains("..")` 的区别:不误伤合法文件名/目录名(如 `a..b.rs`), +/// 且能识别路径中真正独立的 `..` 段(如 `src/../lib` 的 `..`)。sub_path / file_path +/// 约定 POSIX 风格('/' 分隔),反斜杠一并分段以兼容 Windows 传入路径。 +fn has_path_traversal(p: &str) -> bool { + p.split(|c| c == '/' || c == '\\').any(|seg| seg == "..") +} + +/// 工程状态归一:仅接受 active/archived(大小写不敏感),空/未传 → None(应用层视 None 为 active), +/// 非法值退化 None(应用层归一 active)。返回 None 表示"使用默认 active 语义"。 +fn normalize_status(s: Option) -> Option { + match trim_opt(s) { + Some(v) => { + let lower = v.to_lowercase(); + if lower == "active" || lower == "archived" { + Some(lower) + } else { + // 非法值 → 退化为 None(应用层/前端视 None 为 active) + None + } + } + None => None, + } +} + // ============================================================ // IPC 命令 — 工程 CRUD // ============================================================ @@ -109,6 +149,9 @@ pub async fn add_project_module( // created_at/updated_at 由 Repo 内部覆盖,此处占位 created_at: now_millis(), updated_at: now_millis(), + description: trim_opt(input.description), + // status 默认 active(未传/空串归一),仅接受 active/archived 两值,其余退化 active。 + status: normalize_status(input.status), }; let record_id = record.id.clone(); let ok = state @@ -142,8 +185,10 @@ pub async fn update_project_module( && input.path.is_none() && input.git_url.is_none() && input.stack.is_none() + && input.description.is_none() + && input.status.is_none() { - return Err("至少提供一个待更新字段 (name/path/git_url/stack)".to_string()); + return Err("至少提供一个待更新字段 (name/path/git_url/stack/description/status)".to_string()); } // 先校验存在(404 友好错误),再整体更新(保留不可变字段) @@ -168,6 +213,14 @@ pub async fn update_project_module( } existing.git_url = trim_opt(input.git_url); existing.stack = trim_opt(input.stack); + // description 仅在传入时覆盖(部分更新语义);None 表示"未提供字段"。 + if let Some(d) = input.description { + existing.description = trim_opt(Some(d)); + } + // status 仅在传入时覆盖,非 active/archived 退化 active。 + if let Some(s) = input.status { + existing.status = normalize_status(Some(s)); + } let hit = state .project_modules @@ -229,6 +282,8 @@ pub async fn list_project_modules( sort_order: 0, created_at: now_str.clone(), updated_at: now_str, + description: None, + status: Some("active".to_string()), }; if let Err(e) = state.project_modules.insert(module.clone()).await { tracing::warn!("老项目自动补建工程失败(非阻断): {}", e); @@ -559,9 +614,9 @@ pub async fn get_module_file_tree( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - // 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。 + // 路径穿越防御:`..` 段一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。 if let Some(ref s) = sub { - if s.contains("..") { + if has_path_traversal(s) { return Err("sub_path 不允许包含 ..".to_string()); } } @@ -686,8 +741,8 @@ pub async fn read_module_file( if file_path.is_empty() { return Err("file_path 不能为空".to_string()); } - // 路径穿越防御:`..` 一律拒。 - if file_path.contains("..") { + // 路径穿越防御:`..` 段一律拒。 + if has_path_traversal(&file_path) { return Err("file_path 不允许包含 ..".to_string()); } @@ -760,7 +815,7 @@ pub async fn get_module_file_meta( if file_path.is_empty() { return Err("file_path 不能为空".to_string()); } - if file_path.contains("..") { + if has_path_traversal(&file_path) { return Err("file_path 不允许包含 ..".to_string()); } let module = state @@ -805,7 +860,7 @@ pub async fn get_module_file_diff( if file_path.is_empty() { return Err("file_path 不能为空".to_string()); } - if file_path.contains("..") { + if has_path_traversal(&file_path) { return Err("file_path 不允许包含 ..".to_string()); } let module = state @@ -838,8 +893,11 @@ pub async fn get_module_file_diff( })) } -/// 扫描项目绑定目录下的子仓库(含 .git 的直接子目录),自动创建工程记录。 -/// 返回新创建的工程数量。幂等:已存在的路径不重复创建。 +/// 扫描项目绑定目录下的一级子目录,自动创建工程记录。 +/// +/// 判定为工程的信号:① 含 .git(独立仓库)② detect_stack 命中(有 package.json / go.mod / +/// Cargo.toml 等工程标志文件)。两者满足其一即识别;两者都无(纯普通文件夹)忽略。 +/// 无 .git 的工程 git_url 留空。返回新创建的工程数量;幂等(已存在路径不重复创建)。 #[tauri::command] pub async fn scan_project_modules( state: State<'_, AppState>, @@ -885,23 +943,42 @@ pub async fn scan_project_modules( continue; } let child_path = entry.path(); - // 必须含 .git(独立仓库) - if !child_path.join(".git").exists() { continue; } let child_path_str = child_path.to_string_lossy().replace("\\", "/"); if existing_paths.contains(&child_path_str.to_lowercase()) { continue; } - // 获取远程地址(失败忽略);spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime) - let url_dir = child_path.clone(); - let git_url = tokio::task::spawn_blocking(move || { - run_git_cmd( - &url_dir, - &["remote", "get-url", "origin"], - std::time::Duration::from_secs(10), - ) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + let has_git = child_path.join(".git").exists(); + // 探测技术栈:既是「是否工程」的判定信号,也填 stack 字段 + let stack_dir = child_path.clone(); + let stack_json = tokio::task::spawn_blocking(move || { + detect_stack(&stack_dir) + .ok() + .filter(|v| !v.is_empty()) + .and_then(|v| serde_json::to_string(&v).ok()) }) .await .unwrap_or(None); + // 合理判定:有 .git(独立仓库)或 detect_stack 命中(工程标志,如 package.json / + // go.mod / Cargo.toml 等)→ 识别为工程;两者都无 → 纯普通文件夹,忽略。 + // 相比原「仅 .git」,覆盖无独立 git 但有工程标志的子包(monorepo npm workspace 等)。 + if !has_git && stack_json.is_none() { + continue; + } + // 获取远程地址(仅独立 git 仓库有 remote;无 .git 的工程 git_url 留空) + let url_dir = child_path.clone(); + let git_url = if has_git { + tokio::task::spawn_blocking(move || { + run_git_cmd( + &url_dir, + &["remote", "get-url", "origin"], + std::time::Duration::from_secs(10), + ) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .await + .unwrap_or(None) + } else { + None + }; let now_str = now_millis(); let record = ProjectModuleRecord { id: new_id(), @@ -909,11 +986,13 @@ pub async fn scan_project_modules( name: name.clone(), path: child_path.to_string_lossy().to_string(), git_url, - stack: None, + stack: stack_json, auto_detected: true, sort_order: (existing.len() + new_count as usize) as i32, created_at: now_str.clone(), updated_at: now_str, + description: None, + status: Some("active".to_string()), }; if let Err(e) = state.project_modules.insert(record).await { tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e); diff --git a/src/api/module.ts b/src/api/module.ts index e9c277f..60b6fa3 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -19,6 +19,10 @@ export interface ProjectModuleRecord { sort_order: number created_at: string updated_at: string + /** 工程职责描述(如"前端 web 工程""后端 API 服务")。V40 加,老工程为 null。 */ + description?: string | null + /** 工程状态 active/archived。V40 加,老工程为 null(应用层视 null 为 active)。 */ + status?: string | null } /** 文件树单条目(单层;前端点击文件夹再懒加载下一层)。 */ @@ -98,6 +102,10 @@ export const moduleApi = { path: string gitUrl?: string | null stack?: string | null + /** 工程职责描述(V40 加) */ + description?: string | null + /** 工程状态 active/archived(V40 加,默认 active) */ + status?: string | null }): Promise { return invoke('add_project_module', { input }) }, @@ -155,6 +163,10 @@ export const moduleApi = { path?: string gitUrl?: string | null stack?: string | null + /** 工程职责描述(V40 加,部分更新:仅传入时覆盖) */ + description?: string | null + /** 工程状态 active/archived(V40 加,部分更新:仅传入时覆盖) */ + status?: string | null }): Promise { return invoke('update_project_module', { input }) }, diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index 565e088..a601fd1 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -138,9 +138,28 @@ + + + - -
+ +
{{ $t('projectDetail.modulesLabel', { n: modules.length }) }} -
-
- {{ m.name }} - {{ m.path }} +
+ +
+ + +
+ + +
+
📦
+

{{ $t('projectDetail.modulesEmptyHint') }}

+
+ + +
+
+ + +
+
+
+ {{ m.name }} + {{ $t('projectDetail.moduleStatusArchived') }} + {{ $t('projectDetail.moduleStatusActive') }} + {{ m.path }} + + + + +
+

{{ m.description }}

+
+ {{ tag }} +
+
@@ -289,7 +343,48 @@ /> - + + + +
@@ -431,9 +526,9 @@ async function loadProjectTasks() { } // taskStatusLabel / taskStatusClass 由 ../constants/project 提供 -// ── 工程列表(只读展示)── -// 多工程(monorepo/多模块)在 FileExplorer 文件 Tab 内 CRUD,概览仅按 project_id 拉取展示。 -// 0 工程 v-if 隐藏,单工程也显(与多工程同列表)。 +// ── 工程列表(概览内 CRUD)── +// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认) +// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。 const modules = ref([]) async function loadModules() { try { @@ -443,6 +538,112 @@ async function loadModules() { } } +/** + * 工程状态归一:老工程 status 为 null 视作 active(默认活跃)。 + * 仅返回 active / archived 两值,供模板按状态显 badge。 + */ +function moduleStatusOf(m: ProjectModuleRecord): 'active' | 'archived' { + return m.status === 'archived' ? 'archived' : 'active' +} + +// 工程 CRUD 表单状态(对标 FileExplorer 工程 modal) +const showModuleModal = ref(false) +const editingModule = ref(null) +const savingModule = ref(false) +const scanning = ref(false) +const moduleFormName = ref('') +const moduleFormPath = ref('') +const moduleFormGitUrl = ref('') +const moduleFormDescription = ref('') +const moduleFormStack = ref('') +const moduleFormStatus = ref<'active' | 'archived'>('active') + +/** 打开新增工程弹窗(清空表单)。 */ +function openAddModule() { + editingModule.value = null + moduleFormName.value = '' + moduleFormPath.value = '' + moduleFormGitUrl.value = '' + moduleFormDescription.value = '' + moduleFormStack.value = '' + moduleFormStatus.value = 'active' + showModuleModal.value = true +} + +/** 打开编辑工程弹窗(回填当前工程字段;老工程 status=null 视作 active)。 */ +function openEditModule(m: ProjectModuleRecord) { + editingModule.value = m + moduleFormName.value = m.name + moduleFormPath.value = m.path + moduleFormGitUrl.value = m.git_url ?? '' + moduleFormDescription.value = m.description ?? '' + moduleFormStack.value = m.stack ?? '' + moduleFormStatus.value = m.status === 'archived' ? 'archived' : 'active' + showModuleModal.value = true +} + +/** 保存工程(新增/编辑统一入口;成功 toast + 关弹窗 + 重拉列表)。 */ +async function onSaveModule() { + if (savingModule.value) return + if (!moduleFormName.value.trim() || !moduleFormPath.value.trim()) return + savingModule.value = true + try { + const payload = { + name: moduleFormName.value.trim(), + path: moduleFormPath.value.trim(), + gitUrl: moduleFormGitUrl.value.trim() || null, + description: moduleFormDescription.value.trim() || null, + stack: moduleFormStack.value.trim() || null, + status: moduleFormStatus.value, + } + if (editingModule.value) { + await moduleApi.updateProjectModule({ id: editingModule.value.id, ...payload }) + Message.success(t('projectDetail.moduleUpdateSuccess')) + } else { + await moduleApi.addProjectModule({ projectId: projectId.value, ...payload }) + Message.success(t('projectDetail.moduleAddSuccess')) + } + showModuleModal.value = false + await loadModules() + } catch (e) { + Message.error(t('common.unknownError')) + console.error('[ProjectDetail] 保存工程失败:', e) + } finally { + savingModule.value = false + } +} + +/** 删除工程(二次确认后执行;复用 useConfirm 的 Promise 化 confirmDialog)。 */ +async function confirmRemoveModule(m: ProjectModuleRecord) { + const ok = await confirmDialog(t('fileExplorer.removeConfirm', { name: m.name }), t('common.delete')) + if (!ok) return + try { + await moduleApi.removeProjectModule(m.id) + Message.info(t('projectDetail.moduleRemoved')) + await loadModules() + } catch (e) { + Message.error(t('common.unknownError')) + console.error('[ProjectDetail] 删除工程失败:', e) + } +} + +/** 扫描项目绑定目录下的子仓库,自动创建工程记录(幂等)。 */ +async function onScanSubmodules() { + if (scanning.value) return + scanning.value = true + try { + const n = await moduleApi.scanProjectModules(projectId.value) + await loadModules() + if (n > 0) Message.success(t('projectDetail.scanFound', { n })) + else Message.info(t('projectDetail.scanNone')) + } catch (e) { + Message.error(t('common.unknownError')) + console.error('[ProjectDetail] 扫描子仓库失败:', e) + } finally { + scanning.value = false + } +} + // ── 工作流 ── // demoDag + runDemoWorkflow 已下线(R-PD-2):"script" 节点不再注册到 NodeRegistry, // 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。 @@ -677,7 +878,88 @@ onUnmounted(() => { font-style: italic; } -/* ===== 工程列表(概览只读展示;对齐 .info-item label固定宽 + value) ===== */ +/* ===== 工程管理卡片(概览内 CRUD) ===== */ +.module-manager-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background: var(--df-bg-card); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-lg); +} +.module-manager-header { + display: flex; + gap: 8px; + align-items: center; +} +/* 工程行:hover 显操作 + 背景反馈(原 .module-row-overview padding/border-bottom 保留) */ +.module-row-overview { + position: relative; + border-radius: var(--df-radius-sm); + transition: background 0.15s var(--df-ease); +} +.module-row-overview:hover { + background: var(--df-bg-card-hover); +} +.module-row-actions { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s var(--df-ease); +} +.module-row-overview:hover .module-row-actions { + opacity: 1; +} +/* 行内图标钮缩到 22×22(紧凑协调,覆盖全局 .ai-btn-icon 26×26) */ +.module-row-actions .ai-btn-icon { + width: 22px; + height: 22px; +} +.module-row-action-danger:hover { + color: var(--df-danger); +} +/* 无 hover 设备(触屏):操作常显,可达性兜底 */ +@media (hover: none) { + .module-row-actions { opacity: 1; } +} +/* 空态引导 */ +.module-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 12px; + text-align: center; +} +.module-empty-icon { + font-size: 28px; + opacity: 0.6; + line-height: 1; +} +.module-empty-hint { + margin: 0; + font-size: 12px; + color: var(--df-text-dim); + line-height: 1.6; + max-width: 320px; +} +.module-empty-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} +/* modal 内「(可选)」小字标签(对标 FileExplorer) */ +.opt-label { + font-size: 11px; + color: var(--df-text-dim); + font-weight: 400; +} + +/* ===== 工程列表 ===== */ .module-list-overview { display: flex; flex-direction: column; @@ -686,10 +968,22 @@ onUnmounted(() => { min-width: 0; } .module-row-overview { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0; + border-bottom: 1px solid var(--df-border, rgba(0, 0, 0, 0.06)); + min-width: 0; +} +.module-row-overview:last-child { + border-bottom: none; +} +.module-row-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; + flex-wrap: wrap; } .module-name-overview { font-size: 13px; @@ -705,6 +999,43 @@ onUnmounted(() => { overflow: hidden; text-overflow: ellipsis; min-width: 0; + flex: 1; +} +.module-desc-overview { + margin: 0; + font-size: 12px; + color: var(--df-text-secondary); + line-height: 1.5; + /* 限制两行,过长省略;描述通常一句话,两行覆盖 99% 场景 */ + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.module-stack-overview { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.module-stack-overview .tech-tag { + font-size: 11px; + padding: 1px 6px; +} +.module-status-badge { + font-size: 11px; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + flex-shrink: 0; +} +.module-status-active { + color: var(--df-success, #16a34a); + background: rgba(22, 163, 74, 0.1); +} +.module-status-archived { + color: var(--df-text-dim, #888); + background: rgba(136, 136, 136, 0.12); } .status-badge { @@ -713,7 +1044,7 @@ onUnmounted(() => { border-radius: var(--df-radius-lg); font-size: 11px; font-weight: 500; - background: rgba(108,99,255,0.1); + background: var(--df-accent-bg); color: var(--df-accent); } @@ -843,7 +1174,7 @@ onUnmounted(() => { font-weight: 500; } .stage-planning { background: rgba(100,181,246,0.15); color: #64b5f6; } -.stage-in_progress { background: rgba(108,99,255,0.15); color: #6c63ff; } +.stage-in_progress { background: var(--df-accent-soft); color: var(--df-accent); } .stage-testing { background: rgba(255,193,7,0.15); color: #ffc107; } .stage-releasing { background: rgba(156,39,176,0.15); color: #ce93d8; } .stage-completed { background: rgba(100,200,100,0.15); color: #64c864; } @@ -890,7 +1221,7 @@ onUnmounted(() => { .task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .task-status { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); } .status-done { background: rgba(100,255,218,0.15); color: var(--df-success); } -.status-progress { background: rgba(108,99,255,0.15); color: var(--df-accent); } +.status-progress { background: var(--df-accent-soft); color: var(--df-accent); } .status-review { background: rgba(255,217,61,0.15); color: var(--df-warning); } .status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } @@ -905,7 +1236,7 @@ onUnmounted(() => { font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace; color: var(--df-text-secondary); - background: rgba(108,99,255,0.1); + background: var(--df-accent-bg); padding: 1px 6px; border-radius: var(--df-radius-sm); }