diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 1fafe97..7badeeb 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -1198,3 +1198,72 @@ pub async fn list_module_dependencies( .await .map_err(err_str) } + +/// 检测工程依赖图中的环形依赖(DFS 三色标记法)。 +/// 返回参与环的 module_id 列表(空 = 无环)。 +#[tauri::command] +pub async fn detect_module_cycles( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + let project_id = project_id.trim().to_string(); + if project_id.is_empty() { + return Err("project_id 不能为空".to_string()); + } + let deps = state + .module_dependencies + .list_by_field("project_id", &project_id) + .await + .map_err(err_str)?; + // 构建邻接表 + let mut adj: std::collections::HashMap> = std::collections::HashMap::new(); + for d in &deps { + adj.entry(d.from_module_id.clone()) + .or_default() + .push(d.to_module_id.clone()); + adj.entry(d.to_module_id.clone()).or_default(); + } + // DFS 三色:0=白(未访问) 1=灰(栈中) 2=黑(完成) + let mut color: std::collections::HashMap = std::collections::HashMap::new(); + let mut cycle_nodes: std::collections::HashSet = std::collections::HashSet::new(); + fn dfs( + node: &str, + adj: &std::collections::HashMap>, + color: &mut std::collections::HashMap, + cycle_nodes: &mut std::collections::HashSet, + path: &mut Vec, + ) { + let c = color.get(node).copied().unwrap_or(0); + if c == 1 { + // 发现环:path 中从当前节点开始的都参与环 + let in_cycle = path.iter().position(|n| n == node); + if let Some(start) = in_cycle { + for n in &path[start..] { + cycle_nodes.insert(n.clone()); + } + } + cycle_nodes.insert(node.to_string()); + return; + } + if c == 2 { + return; + } + color.insert(node.to_string(), 1); + path.push(node.to_string()); + if let Some(neighbors) = adj.get(node) { + for next in neighbors { + dfs(next, adj, color, cycle_nodes, path); + } + } + path.pop(); + color.insert(node.to_string(), 2); + } + let nodes: Vec = adj.keys().cloned().collect(); + let mut path: Vec = Vec::new(); + for n in &nodes { + if color.get(n).copied().unwrap_or(0) == 0 { + dfs(n, &adj, &mut color, &mut cycle_nodes, &mut path); + } + } + Ok(cycle_nodes.into_iter().collect()) +} diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index c6d5446..aa91234 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -203,6 +203,16 @@ pub async fn list_tasks( Ok(tasks) } +/// 按条件计数任务(分页 total 用)。 +#[tauri::command] +pub async fn count_tasks( + state: State<'_, AppState>, + query: Option, +) -> Result { + let q = query.unwrap_or_default(); + state.tasks.count_by_query(&q).await.map_err(err_str) +} + /// 按 id 查任务,找不到返回 Err(供前端详情页) #[tauri::command] pub async fn get_task_by_id( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7590f4..e3f0ce2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -300,6 +300,7 @@ pub fn run() { commands::project::import_projects_batch, // 任务 commands::task::list_tasks, + commands::task::count_tasks, commands::task::create_task, commands::task::update_task, commands::task::delete_task, @@ -337,6 +338,7 @@ pub fn run() { commands::module::add_module_dependency, commands::module::remove_module_dependency, commands::module::list_module_dependencies, + commands::module::detect_module_cycles, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, diff --git a/src/api/module.ts b/src/api/module.ts index a5b9caa..d26b9db 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -168,6 +168,11 @@ export const moduleApi = { return invoke('list_branches', { moduleId }) }, + /** 检测工程依赖环形依赖,返回参与环的 module_id 列表(空=无环)。 */ + detectModuleCycles(projectId: string): Promise { + return invoke('detect_module_cycles', { projectId }) + }, + /** 列出项目全部工程依赖。 */ listModuleDependencies(projectId: string): Promise { return invoke('list_module_dependencies', { projectId }) diff --git a/src/api/task.ts b/src/api/task.ts index 7932225..8e66990 100644 --- a/src/api/task.ts +++ b/src/api/task.ts @@ -18,6 +18,11 @@ export const taskApi = { return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null }) }, + /** 按条件计数任务(分页 total 用)。 */ + count(query?: TaskQuery): Promise { + return invoke('count_tasks', { query: query ?? null }) + }, + get(id: string): Promise { return invoke('get_task_by_id', { id }) }, diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index bdfba8f..2942b1a 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -4,6 +4,8 @@ {{ $t('dependencyGraph.title') }}
+ + @@ -211,6 +213,43 @@ function zoomOut() { graph?.zoom(-0.1) } +/** 环形依赖检测。 */ +const cycleNodes = ref>(new Set()) +async function checkCycles() { + try { + const cycles = await moduleApi.detectModuleCycles(props.projectId) + cycleNodes.value = new Set(cycles) + if (cycles.length > 0) { + // 高亮环节点(加红色边框) + for (const id of cycles) { + const cell = graph?.getCellById(id) + if (cell) { + cell.attr('body/stroke', '#e05050') + cell.attr('body/strokeWidth', 3) + } + } + } + renderGraph() + } catch (e) { + console.error('[DependencyGraph] 环检测失败:', e) + } +} + +/** 导出 PNG。 */ +async function exportPNG() { + if (!graph) return + try { + graph.toPNG((dataUri: string) => { + const a = document.createElement('a') + a.href = dataUri + a.download = `dependency-graph-${props.projectId}.png` + a.click() + }) + } catch (e) { + console.error('[DependencyGraph] 导出 PNG 失败:', e) + } +} + /** 依赖类型 → 边颜色 */ function depTypeColor(depType: string): string { switch (depType) { diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index 99c9234..865d812 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -252,7 +252,10 @@ watch(searchKeyword, () => { _searchTimer = setTimeout(() => { page.value = 1 loading.value = true - store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) + Promise.all([ + store.loadTasks(buildTaskQuery()), + taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n), + ]).finally(() => { loading.value = false }) }, 300) }) @@ -383,20 +386,29 @@ watch(activeProject, () => { store.setActiveTaskProject(activeProject.value) page.value = 1 loading.value = true - store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) + Promise.all([ + store.loadTasks(buildTaskQuery()), + taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n), + ]).finally(() => { loading.value = false }) }) watch(activeStatus, () => { store.setActiveTaskStatus(activeStatus.value) page.value = 1 loading.value = true - store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) + Promise.all([ + store.loadTasks(buildTaskQuery()), + taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n), + ]).finally(() => { loading.value = false }) }) watch(sortBy, () => { page.value = 1 loading.value = true - store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) + Promise.all([ + store.loadTasks(buildTaskQuery()), + taskApi.count(buildTaskQuery()).then(n => totalTasks.value = n), + ]).finally(() => { loading.value = false }) }) // Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键) @@ -415,7 +427,11 @@ onMounted(async () => { store.setActiveTaskStatus(activeStatus.value) loading.value = true try { - await Promise.all([store.loadProjects(), store.loadTasks()]) + await Promise.all([ + store.loadProjects(), + store.loadTasks(), + taskApi.count().then(n => totalTasks.value = n), + ]) } finally { loading.value = false }