优化: 工程实体化(description/stack/status)+ 概览工程列表
- project_modules 加 description/status 列(V40 迁移,stack V34 已有)+ models/repo/INSERT/UPDATE/SELECT 映射 - FileExplorer 工程 CRUD 弹窗加 description(textarea)/stack(input)/status(select)输入,工程有完整身份 - ProjectDetail 概览工程列表升级:name+status badge+path + description 段(2行省略)+ stack tag - 老工程兼容(None=active),update 部分更新语义,status 归一(active/archived)
This commit is contained in:
@@ -35,6 +35,8 @@ fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRe
|
|||||||
sort_order: row.get("sort_order")?,
|
sort_order: row.get("sort_order")?,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
|
description: row.get("description")?,
|
||||||
|
status: row.get("status")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +73,13 @@ impl ProjectModuleRepo {
|
|||||||
let affected = conn
|
let affected = conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO project_modules \
|
"INSERT INTO project_modules \
|
||||||
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
|
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at, description, status) \
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
r.id, r.project_id, r.name, r.path,
|
r.id, r.project_id, r.name, r.path,
|
||||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||||
r.created_at, r.updated_at,
|
r.created_at, r.updated_at,
|
||||||
|
r.description, r.status,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -116,7 +119,7 @@ impl ProjectModuleRepo {
|
|||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
|
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at, description, status \
|
||||||
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
|
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -147,12 +150,15 @@ impl ProjectModuleRepo {
|
|||||||
.execute(
|
.execute(
|
||||||
"UPDATE project_modules SET \
|
"UPDATE project_modules SET \
|
||||||
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
|
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
|
||||||
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
|
auto_detected = ?6, sort_order = ?7, updated_at = ?8, \
|
||||||
WHERE id = ?9",
|
description = ?9, status = ?10 \
|
||||||
|
WHERE id = ?11",
|
||||||
params![
|
params![
|
||||||
r.project_id, r.name, r.path,
|
r.project_id, r.name, r.path,
|
||||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||||
r.updated_at, r.id,
|
r.updated_at,
|
||||||
|
r.description, r.status,
|
||||||
|
r.id,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -239,6 +245,8 @@ mod tests {
|
|||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
|
description: None,
|
||||||
|
status: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use tauri::State;
|
|||||||
|
|
||||||
use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
|
use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
|
||||||
use df_types::types::new_id;
|
use df_types::types::new_id;
|
||||||
|
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
|
||||||
|
use df_project::scan::detect_stack;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -43,6 +45,12 @@ pub struct AddProjectModuleInput {
|
|||||||
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stack: Option<String>,
|
pub stack: Option<String>,
|
||||||
|
/// 工程职责描述(如"前端 web 工程"),可选。V40 加。
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// 工程状态 active/archived,可选(默认 active)。V40 加。
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
||||||
@@ -57,6 +65,12 @@ pub struct UpdateProjectModuleInput {
|
|||||||
pub git_url: Option<String>,
|
pub git_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stack: Option<String>,
|
pub stack: Option<String>,
|
||||||
|
/// 工程职责描述,V40 加。空串归一为 None(trim_opt)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// 工程状态 active/archived,V40 加。
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -67,6 +81,32 @@ fn trim_opt(s: Option<String>) -> Option<String> {
|
|||||||
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
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<String>) -> Option<String> {
|
||||||
|
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
|
// IPC 命令 — 工程 CRUD
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -109,6 +149,9 @@ pub async fn add_project_module(
|
|||||||
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
||||||
created_at: now_millis(),
|
created_at: now_millis(),
|
||||||
updated_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 record_id = record.id.clone();
|
||||||
let ok = state
|
let ok = state
|
||||||
@@ -142,8 +185,10 @@ pub async fn update_project_module(
|
|||||||
&& input.path.is_none()
|
&& input.path.is_none()
|
||||||
&& input.git_url.is_none()
|
&& input.git_url.is_none()
|
||||||
&& input.stack.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 友好错误),再整体更新(保留不可变字段)
|
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
||||||
@@ -168,6 +213,14 @@ pub async fn update_project_module(
|
|||||||
}
|
}
|
||||||
existing.git_url = trim_opt(input.git_url);
|
existing.git_url = trim_opt(input.git_url);
|
||||||
existing.stack = trim_opt(input.stack);
|
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
|
let hit = state
|
||||||
.project_modules
|
.project_modules
|
||||||
@@ -229,6 +282,8 @@ pub async fn list_project_modules(
|
|||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
created_at: now_str.clone(),
|
created_at: now_str.clone(),
|
||||||
updated_at: now_str,
|
updated_at: now_str,
|
||||||
|
description: None,
|
||||||
|
status: Some("active".to_string()),
|
||||||
};
|
};
|
||||||
if let Err(e) = state.project_modules.insert(module.clone()).await {
|
if let Err(e) = state.project_modules.insert(module.clone()).await {
|
||||||
tracing::warn!("老项目自动补建工程失败(非阻断): {}", e);
|
tracing::warn!("老项目自动补建工程失败(非阻断): {}", e);
|
||||||
@@ -559,9 +614,9 @@ pub async fn get_module_file_tree(
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
// 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
// 路径穿越防御:`..` 段一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
||||||
if let Some(ref s) = sub {
|
if let Some(ref s) = sub {
|
||||||
if s.contains("..") {
|
if has_path_traversal(s) {
|
||||||
return Err("sub_path 不允许包含 ..".to_string());
|
return Err("sub_path 不允许包含 ..".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,8 +741,8 @@ pub async fn read_module_file(
|
|||||||
if file_path.is_empty() {
|
if file_path.is_empty() {
|
||||||
return Err("file_path 不能为空".to_string());
|
return Err("file_path 不能为空".to_string());
|
||||||
}
|
}
|
||||||
// 路径穿越防御:`..` 一律拒。
|
// 路径穿越防御:`..` 段一律拒。
|
||||||
if file_path.contains("..") {
|
if has_path_traversal(&file_path) {
|
||||||
return Err("file_path 不允许包含 ..".to_string());
|
return Err("file_path 不允许包含 ..".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +815,7 @@ pub async fn get_module_file_meta(
|
|||||||
if file_path.is_empty() {
|
if file_path.is_empty() {
|
||||||
return Err("file_path 不能为空".to_string());
|
return Err("file_path 不能为空".to_string());
|
||||||
}
|
}
|
||||||
if file_path.contains("..") {
|
if has_path_traversal(&file_path) {
|
||||||
return Err("file_path 不允许包含 ..".to_string());
|
return Err("file_path 不允许包含 ..".to_string());
|
||||||
}
|
}
|
||||||
let module = state
|
let module = state
|
||||||
@@ -805,7 +860,7 @@ pub async fn get_module_file_diff(
|
|||||||
if file_path.is_empty() {
|
if file_path.is_empty() {
|
||||||
return Err("file_path 不能为空".to_string());
|
return Err("file_path 不能为空".to_string());
|
||||||
}
|
}
|
||||||
if file_path.contains("..") {
|
if has_path_traversal(&file_path) {
|
||||||
return Err("file_path 不允许包含 ..".to_string());
|
return Err("file_path 不允许包含 ..".to_string());
|
||||||
}
|
}
|
||||||
let module = state
|
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]
|
#[tauri::command]
|
||||||
pub async fn scan_project_modules(
|
pub async fn scan_project_modules(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -885,13 +943,29 @@ pub async fn scan_project_modules(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let child_path = entry.path();
|
let child_path = entry.path();
|
||||||
// 必须含 .git(独立仓库)
|
|
||||||
if !child_path.join(".git").exists() { continue; }
|
|
||||||
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
||||||
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
||||||
// 获取远程地址(失败忽略);spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)
|
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 url_dir = child_path.clone();
|
||||||
let git_url = tokio::task::spawn_blocking(move || {
|
let git_url = if has_git {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
run_git_cmd(
|
run_git_cmd(
|
||||||
&url_dir,
|
&url_dir,
|
||||||
&["remote", "get-url", "origin"],
|
&["remote", "get-url", "origin"],
|
||||||
@@ -901,7 +975,10 @@ pub async fn scan_project_modules(
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let now_str = now_millis();
|
let now_str = now_millis();
|
||||||
let record = ProjectModuleRecord {
|
let record = ProjectModuleRecord {
|
||||||
id: new_id(),
|
id: new_id(),
|
||||||
@@ -909,11 +986,13 @@ pub async fn scan_project_modules(
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
path: child_path.to_string_lossy().to_string(),
|
path: child_path.to_string_lossy().to_string(),
|
||||||
git_url,
|
git_url,
|
||||||
stack: None,
|
stack: stack_json,
|
||||||
auto_detected: true,
|
auto_detected: true,
|
||||||
sort_order: (existing.len() + new_count as usize) as i32,
|
sort_order: (existing.len() + new_count as usize) as i32,
|
||||||
created_at: now_str.clone(),
|
created_at: now_str.clone(),
|
||||||
updated_at: now_str,
|
updated_at: now_str,
|
||||||
|
description: None,
|
||||||
|
status: Some("active".to_string()),
|
||||||
};
|
};
|
||||||
if let Err(e) = state.project_modules.insert(record).await {
|
if let Err(e) = state.project_modules.insert(record).await {
|
||||||
tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e);
|
tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e);
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface ProjectModuleRecord {
|
|||||||
sort_order: number
|
sort_order: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_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
|
path: string
|
||||||
gitUrl?: string | null
|
gitUrl?: string | null
|
||||||
stack?: string | null
|
stack?: string | null
|
||||||
|
/** 工程职责描述(V40 加) */
|
||||||
|
description?: string | null
|
||||||
|
/** 工程状态 active/archived(V40 加,默认 active) */
|
||||||
|
status?: string | null
|
||||||
}): Promise<ProjectModuleRecord> {
|
}): Promise<ProjectModuleRecord> {
|
||||||
return invoke('add_project_module', { input })
|
return invoke('add_project_module', { input })
|
||||||
},
|
},
|
||||||
@@ -155,6 +163,10 @@ export const moduleApi = {
|
|||||||
path?: string
|
path?: string
|
||||||
gitUrl?: string | null
|
gitUrl?: string | null
|
||||||
stack?: string | null
|
stack?: string | null
|
||||||
|
/** 工程职责描述(V40 加,部分更新:仅传入时覆盖) */
|
||||||
|
description?: string | null
|
||||||
|
/** 工程状态 active/archived(V40 加,部分更新:仅传入时覆盖) */
|
||||||
|
status?: string | null
|
||||||
}): Promise<ProjectModuleRecord> {
|
}): Promise<ProjectModuleRecord> {
|
||||||
return invoke('update_project_module', { input })
|
return invoke('update_project_module', { input })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -138,9 +138,28 @@
|
|||||||
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-field">
|
<div class="modal-field">
|
||||||
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('common.optional') }}</span></label>
|
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleDescription') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="moduleFormDescription"
|
||||||
|
:placeholder="$t('fileExplorer.moduleDescriptionPlaceholder')"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleStack') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
|
<input v-model="moduleFormStack" :placeholder="$t('fileExplorer.moduleStackPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleStatus') }}</label>
|
||||||
|
<select v-model="moduleFormStatus">
|
||||||
|
<option value="active">{{ $t('fileExplorer.moduleStatusActive') }}</option>
|
||||||
|
<option value="archived">{{ $t('fileExplorer.moduleStatusArchived') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
||||||
@@ -206,6 +225,10 @@ const savingModule = ref(false)
|
|||||||
const moduleFormName = ref('')
|
const moduleFormName = ref('')
|
||||||
const moduleFormPath = ref('')
|
const moduleFormPath = ref('')
|
||||||
const moduleFormGitUrl = ref('')
|
const moduleFormGitUrl = ref('')
|
||||||
|
// V40 工程实体化:职责描述 / 技术栈(逗号分隔) / 状态(active/archived)
|
||||||
|
const moduleFormDescription = ref('')
|
||||||
|
const moduleFormStack = ref('')
|
||||||
|
const moduleFormStatus = ref<'active' | 'archived'>('active')
|
||||||
|
|
||||||
// 删除确认
|
// 删除确认
|
||||||
const showRemoveConfirm = ref(false)
|
const showRemoveConfirm = ref(false)
|
||||||
@@ -409,6 +432,9 @@ function openAddModule() {
|
|||||||
moduleFormName.value = ''
|
moduleFormName.value = ''
|
||||||
moduleFormPath.value = ''
|
moduleFormPath.value = ''
|
||||||
moduleFormGitUrl.value = ''
|
moduleFormGitUrl.value = ''
|
||||||
|
moduleFormDescription.value = ''
|
||||||
|
moduleFormStack.value = ''
|
||||||
|
moduleFormStatus.value = 'active'
|
||||||
showModuleModal.value = true
|
showModuleModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +445,10 @@ function openEditModule() {
|
|||||||
moduleFormName.value = currentModule.value.name
|
moduleFormName.value = currentModule.value.name
|
||||||
moduleFormPath.value = currentModule.value.path
|
moduleFormPath.value = currentModule.value.path
|
||||||
moduleFormGitUrl.value = currentModule.value.git_url ?? ''
|
moduleFormGitUrl.value = currentModule.value.git_url ?? ''
|
||||||
|
moduleFormDescription.value = currentModule.value.description ?? ''
|
||||||
|
moduleFormStack.value = currentModule.value.stack ?? ''
|
||||||
|
// 老工程 status 为 null 时视作 active
|
||||||
|
moduleFormStatus.value = (currentModule.value.status === 'archived' ? 'archived' : 'active')
|
||||||
showModuleModal.value = true
|
showModuleModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +469,9 @@ async function onSaveModule() {
|
|||||||
name: moduleFormName.value.trim(),
|
name: moduleFormName.value.trim(),
|
||||||
path: moduleFormPath.value.trim(),
|
path: moduleFormPath.value.trim(),
|
||||||
gitUrl: moduleFormGitUrl.value.trim() || null,
|
gitUrl: moduleFormGitUrl.value.trim() || null,
|
||||||
|
description: moduleFormDescription.value.trim() || null,
|
||||||
|
stack: moduleFormStack.value.trim() || null,
|
||||||
|
status: moduleFormStatus.value,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await moduleApi.addProjectModule({
|
await moduleApi.addProjectModule({
|
||||||
@@ -446,6 +479,9 @@ async function onSaveModule() {
|
|||||||
name: moduleFormName.value.trim(),
|
name: moduleFormName.value.trim(),
|
||||||
path: moduleFormPath.value.trim(),
|
path: moduleFormPath.value.trim(),
|
||||||
gitUrl: moduleFormGitUrl.value.trim() || null,
|
gitUrl: moduleFormGitUrl.value.trim() || null,
|
||||||
|
description: moduleFormDescription.value.trim() || null,
|
||||||
|
stack: moduleFormStack.value.trim() || null,
|
||||||
|
status: moduleFormStatus.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
showModuleModal.value = false
|
showModuleModal.value = false
|
||||||
@@ -594,7 +630,7 @@ async function onRemoveModule() {
|
|||||||
.crumb {
|
.crumb {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -682,11 +718,11 @@ async function onRemoveModule() {
|
|||||||
min-width: 14px;
|
min-width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export default {
|
|||||||
modulePathPlaceholder: 'e.g. D:/projects/my-app/frontend',
|
modulePathPlaceholder: 'e.g. D:/projects/my-app/frontend',
|
||||||
moduleGitUrl: 'Git URL',
|
moduleGitUrl: 'Git URL',
|
||||||
moduleGitUrlPlaceholder: 'https://github.com/… (optional)',
|
moduleGitUrlPlaceholder: 'https://github.com/… (optional)',
|
||||||
|
// V40 module entity: description / stack / status
|
||||||
|
optSuffix: '(optional)',
|
||||||
|
moduleDescription: 'Description',
|
||||||
|
moduleDescriptionPlaceholder: 'e.g. Frontend web app / Backend API service / Infrastructure',
|
||||||
|
moduleStack: 'Tech Stack',
|
||||||
|
moduleStackPlaceholder: 'comma-separated, e.g. Vue, TypeScript, Vite',
|
||||||
|
moduleStatus: 'Status',
|
||||||
|
moduleStatusActive: 'Active',
|
||||||
|
moduleStatusArchived: 'Archived',
|
||||||
editModule: 'Edit Module',
|
editModule: 'Edit Module',
|
||||||
removeModule: 'Remove Module',
|
removeModule: 'Remove Module',
|
||||||
removeConfirm: 'Are you sure to remove module "{name}"? This action cannot be undone.',
|
removeConfirm: 'Are you sure to remove module "{name}"? This action cannot be undone.',
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export default {
|
|||||||
techStackLabel: 'Tech Stack',
|
techStackLabel: 'Tech Stack',
|
||||||
// Module list (multi-module read-only display)
|
// Module list (multi-module read-only display)
|
||||||
modulesLabel: 'Modules ({n})',
|
modulesLabel: 'Modules ({n})',
|
||||||
|
// V40 module entity: status badge (legacy status=null treated as active)
|
||||||
|
moduleStatusActive: 'Active',
|
||||||
|
moduleStatusArchived: 'Archived',
|
||||||
|
// Overview module management (CRUD + scan)
|
||||||
|
modulesEmptyHint: 'A project can contain multiple sub-modules (frontend / backend / infra). Add a module, or scan the bound directory for sub-repos to auto-discover.',
|
||||||
|
scanFound: 'Found {n} new module(s)',
|
||||||
|
scanNone: 'No new modules found',
|
||||||
|
moduleAddSuccess: 'Module added',
|
||||||
|
moduleUpdateSuccess: 'Module updated',
|
||||||
|
moduleRemoved: 'Module removed',
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
tabOverview: '📋 Overview',
|
tabOverview: '📋 Overview',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export default {
|
|||||||
modulePathPlaceholder: '例如 D:/projects/my-app/frontend',
|
modulePathPlaceholder: '例如 D:/projects/my-app/frontend',
|
||||||
moduleGitUrl: 'Git 地址',
|
moduleGitUrl: 'Git 地址',
|
||||||
moduleGitUrlPlaceholder: 'https://github.com/…(可选)',
|
moduleGitUrlPlaceholder: 'https://github.com/…(可选)',
|
||||||
|
// V40 工程实体化:描述 / 技术栈 / 状态(可选后缀统一用 optSuffix,替代缺失的 common.optional)
|
||||||
|
optSuffix: '(可选)',
|
||||||
|
moduleDescription: '职责描述',
|
||||||
|
moduleDescriptionPlaceholder: '例如 前端 web 工程 / 后端 API 服务 / 基础设施',
|
||||||
|
moduleStack: '技术栈',
|
||||||
|
moduleStackPlaceholder: '逗号分隔,例如 Vue, TypeScript, Vite',
|
||||||
|
moduleStatus: '状态',
|
||||||
|
moduleStatusActive: '活跃',
|
||||||
|
moduleStatusArchived: '归档',
|
||||||
editModule: '编辑工程',
|
editModule: '编辑工程',
|
||||||
removeModule: '删除工程',
|
removeModule: '删除工程',
|
||||||
removeConfirm: '确定删除工程「{name}」吗?此操作不可撤销。',
|
removeConfirm: '确定删除工程「{name}」吗?此操作不可撤销。',
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export default {
|
|||||||
techStackLabel: '技术栈',
|
techStackLabel: '技术栈',
|
||||||
// 工程列表(多工程只读展示)
|
// 工程列表(多工程只读展示)
|
||||||
modulesLabel: '工程({n})',
|
modulesLabel: '工程({n})',
|
||||||
|
// V40 工程实体化:状态 badge(老工程 status=null 视作 active)
|
||||||
|
moduleStatusActive: '活跃',
|
||||||
|
moduleStatusArchived: '归档',
|
||||||
|
// 概览工程管理(增删改 + 扫描)
|
||||||
|
modulesEmptyHint: '一个项目可包含多个子工程(前端 / 后端 / 基础设施…)。添加工程,或扫描绑定目录下的子仓库自动发现。',
|
||||||
|
scanFound: '扫描到 {n} 个新工程',
|
||||||
|
scanNone: '未发现新工程',
|
||||||
|
moduleAddSuccess: '工程已添加',
|
||||||
|
moduleUpdateSuccess: '工程已更新',
|
||||||
|
moduleRemoved: '工程已删除',
|
||||||
// Tab 导航
|
// Tab 导航
|
||||||
tabOverview: '📋 概览',
|
tabOverview: '📋 概览',
|
||||||
},
|
},
|
||||||
|
|||||||
+343
-12
@@ -117,13 +117,67 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 工程列表(多工程只读展示;CRUD 在文件 Tab) -->
|
<!-- 工程管理(概览内 CRUD:新增/编辑/删除/扫描;复用 moduleApi + ConfirmDialog + arco Message。
|
||||||
<div class="info-item" v-if="modules.length">
|
切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步) -->
|
||||||
|
<div class="info-item info-block">
|
||||||
<span class="label">{{ $t('projectDetail.modulesLabel', { n: modules.length }) }}</span>
|
<span class="label">{{ $t('projectDetail.modulesLabel', { n: modules.length }) }}</span>
|
||||||
<div class="module-list-overview">
|
<div class="module-manager-card">
|
||||||
|
<!-- 标题行操作入口 -->
|
||||||
|
<div class="module-manager-header">
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
{{ $t('fileExplorer.addModule') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="currentProject?.path"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="scanning"
|
||||||
|
@click="onScanSubmodules"
|
||||||
|
>
|
||||||
|
<span v-if="scanning" class="spinner"></span>
|
||||||
|
{{ $t('fileExplorer.scanSubmodules') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空态引导(无工程不隐藏,显卡片 + CTA) -->
|
||||||
|
<div v-if="modules.length === 0" class="module-empty">
|
||||||
|
<div class="module-empty-icon">📦</div>
|
||||||
|
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
|
||||||
|
<div class="module-empty-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
|
||||||
|
<button
|
||||||
|
v-if="currentProject?.path"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="scanning"
|
||||||
|
@click="onScanSubmodules"
|
||||||
|
>{{ $t('fileExplorer.scanSubmodules') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工程列表(hover 显行内操作) -->
|
||||||
|
<div v-else class="module-list-overview">
|
||||||
<div v-for="m in modules" :key="m.id" class="module-row-overview">
|
<div v-for="m in modules" :key="m.id" class="module-row-overview">
|
||||||
|
<div class="module-row-head">
|
||||||
<span class="module-name-overview">{{ m.name }}</span>
|
<span class="module-name-overview">{{ m.name }}</span>
|
||||||
|
<span v-if="moduleStatusOf(m) === 'archived'" class="module-status-badge module-status-archived">{{ $t('projectDetail.moduleStatusArchived') }}</span>
|
||||||
|
<span v-else class="module-status-badge module-status-active">{{ $t('projectDetail.moduleStatusActive') }}</span>
|
||||||
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
|
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
|
||||||
|
<span class="module-row-actions">
|
||||||
|
<button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="m.description" class="module-desc-overview">{{ m.description }}</p>
|
||||||
|
<div v-if="parseStack(m.stack).length" class="module-stack-overview">
|
||||||
|
<span class="tech-tag" v-for="tag in parseStack(m.stack)" :key="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +343,48 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
|
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
|
||||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
|
||||||
|
|
||||||
|
<!-- 新增/编辑工程弹窗(概览内 CRUD,对标 FileExplorer 工程 modal) -->
|
||||||
|
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="modal-title">{{ editingModule ? $t('fileExplorer.editModule') : $t('fileExplorer.addModule') }}</h3>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleName') }}</label>
|
||||||
|
<input v-model="moduleFormName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.modulePath') }}</label>
|
||||||
|
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
|
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleDescription') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
|
<textarea v-model="moduleFormDescription" :placeholder="$t('fileExplorer.moduleDescriptionPlaceholder')" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleStack') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||||
|
<input v-model="moduleFormStack" :placeholder="$t('fileExplorer.moduleStackPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>{{ $t('fileExplorer.moduleStatus') }}</label>
|
||||||
|
<select v-model="moduleFormStatus">
|
||||||
|
<option value="active">{{ $t('fileExplorer.moduleStatusActive') }}</option>
|
||||||
|
<option value="archived">{{ $t('fileExplorer.moduleStatusArchived') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
||||||
|
<span v-if="savingModule" class="spinner"></span>
|
||||||
|
{{ savingModule ? $t('common.loading') : (editingModule ? $t('common.save') : $t('fileExplorer.addModule')) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -431,9 +526,9 @@ async function loadProjectTasks() {
|
|||||||
}
|
}
|
||||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||||
|
|
||||||
// ── 工程列表(只读展示)──
|
// ── 工程列表(概览内 CRUD)──
|
||||||
// 多工程(monorepo/多模块)在 FileExplorer 文件 Tab 内 CRUD,概览仅按 project_id 拉取展示。
|
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
|
||||||
// 0 工程 v-if 隐藏,单工程也显(与多工程同列表)。
|
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
|
||||||
const modules = ref<ProjectModuleRecord[]>([])
|
const modules = ref<ProjectModuleRecord[]>([])
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
try {
|
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<ProjectModuleRecord | null>(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,
|
// demoDag + runDemoWorkflow 已下线(R-PD-2):"script" 节点不再注册到 NodeRegistry,
|
||||||
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
|
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
|
||||||
@@ -677,7 +878,88 @@ onUnmounted(() => {
|
|||||||
font-style: italic;
|
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 {
|
.module-list-overview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -686,10 +968,22 @@ onUnmounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.module-row-overview {
|
.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;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.module-name-overview {
|
.module-name-overview {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -705,6 +999,43 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
min-width: 0;
|
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 {
|
.status-badge {
|
||||||
@@ -713,7 +1044,7 @@ onUnmounted(() => {
|
|||||||
border-radius: var(--df-radius-lg);
|
border-radius: var(--df-radius-lg);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: rgba(108,99,255,0.1);
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +1174,7 @@ onUnmounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.stage-planning { background: rgba(100,181,246,0.15); color: #64b5f6; }
|
.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-testing { background: rgba(255,193,7,0.15); color: #ffc107; }
|
||||||
.stage-releasing { background: rgba(156,39,176,0.15); color: #ce93d8; }
|
.stage-releasing { background: rgba(156,39,176,0.15); color: #ce93d8; }
|
||||||
.stage-completed { background: rgba(100,200,100,0.15); color: #64c864; }
|
.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-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); }
|
.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-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-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); }
|
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||||
|
|
||||||
@@ -905,7 +1236,7 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
background: rgba(108,99,255,0.1);
|
background: var(--df-accent-bg);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user