新增: 依赖图环形检测 + PNG 导出 + 任务列表真实总数
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖) - 前端环检测按钮:高亮参与环的节点(红色边框) - 图导出 PNG(X6 toPNG 回调模式) - 后端 count_tasks IPC + 前端 taskApi.count() - 任务列表所有筛选/搜索/排序/翻页均拉真实 total
This commit is contained in:
@@ -1198,3 +1198,72 @@ pub async fn list_module_dependencies(
|
|||||||
.await
|
.await
|
||||||
.map_err(err_str)
|
.map_err(err_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检测工程依赖图中的环形依赖(DFS 三色标记法)。
|
||||||
|
/// 返回参与环的 module_id 列表(空 = 无环)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_module_cycles(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
project_id: String,
|
||||||
|
) -> Result<Vec<String>, 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<String, Vec<String>> = 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<String, u8> = std::collections::HashMap::new();
|
||||||
|
let mut cycle_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
fn dfs(
|
||||||
|
node: &str,
|
||||||
|
adj: &std::collections::HashMap<String, Vec<String>>,
|
||||||
|
color: &mut std::collections::HashMap<String, u8>,
|
||||||
|
cycle_nodes: &mut std::collections::HashSet<String>,
|
||||||
|
path: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
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<String> = adj.keys().cloned().collect();
|
||||||
|
let mut path: Vec<String> = 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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ pub async fn list_tasks(
|
|||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按条件计数任务(分页 total 用)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn count_tasks(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
query: Option<TaskQuery>,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
let q = query.unwrap_or_default();
|
||||||
|
state.tasks.count_by_query(&q).await.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
/// 按 id 查任务,找不到返回 Err(供前端详情页)
|
/// 按 id 查任务,找不到返回 Err(供前端详情页)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_task_by_id(
|
pub async fn get_task_by_id(
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ pub fn run() {
|
|||||||
commands::project::import_projects_batch,
|
commands::project::import_projects_batch,
|
||||||
// 任务
|
// 任务
|
||||||
commands::task::list_tasks,
|
commands::task::list_tasks,
|
||||||
|
commands::task::count_tasks,
|
||||||
commands::task::create_task,
|
commands::task::create_task,
|
||||||
commands::task::update_task,
|
commands::task::update_task,
|
||||||
commands::task::delete_task,
|
commands::task::delete_task,
|
||||||
@@ -337,6 +338,7 @@ pub fn run() {
|
|||||||
commands::module::add_module_dependency,
|
commands::module::add_module_dependency,
|
||||||
commands::module::remove_module_dependency,
|
commands::module::remove_module_dependency,
|
||||||
commands::module::list_module_dependencies,
|
commands::module::list_module_dependencies,
|
||||||
|
commands::module::detect_module_cycles,
|
||||||
// 灵感
|
// 灵感
|
||||||
commands::idea::list_ideas,
|
commands::idea::list_ideas,
|
||||||
commands::idea::create_idea,
|
commands::idea::create_idea,
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ export const moduleApi = {
|
|||||||
return invoke('list_branches', { moduleId })
|
return invoke('list_branches', { moduleId })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 检测工程依赖环形依赖,返回参与环的 module_id 列表(空=无环)。 */
|
||||||
|
detectModuleCycles(projectId: string): Promise<string[]> {
|
||||||
|
return invoke('detect_module_cycles', { projectId })
|
||||||
|
},
|
||||||
|
|
||||||
/** 列出项目全部工程依赖。 */
|
/** 列出项目全部工程依赖。 */
|
||||||
listModuleDependencies(projectId: string): Promise<ModuleDependencyRecord[]> {
|
listModuleDependencies(projectId: string): Promise<ModuleDependencyRecord[]> {
|
||||||
return invoke('list_module_dependencies', { projectId })
|
return invoke('list_module_dependencies', { projectId })
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const taskApi = {
|
|||||||
return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null })
|
return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 按条件计数任务(分页 total 用)。 */
|
||||||
|
count(query?: TaskQuery): Promise<number> {
|
||||||
|
return invoke('count_tasks', { query: query ?? null })
|
||||||
|
},
|
||||||
|
|
||||||
get(id: string): Promise<TaskRecord> {
|
get(id: string): Promise<TaskRecord> {
|
||||||
return invoke('get_task_by_id', { id })
|
return invoke('get_task_by_id', { id })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<span class="dep-graph__title">{{ $t('dependencyGraph.title') }}</span>
|
<span class="dep-graph__title">{{ $t('dependencyGraph.title') }}</span>
|
||||||
<div class="dep-graph__actions">
|
<div class="dep-graph__actions">
|
||||||
<button v-if="modules.length >= 2" class="btn btn-ghost btn-sm" @click="showAddDep = true">+ 依赖</button>
|
<button v-if="modules.length >= 2" class="btn btn-ghost btn-sm" @click="showAddDep = true">+ 依赖</button>
|
||||||
|
<button v-if="dependencies.length > 0" class="btn btn-ghost btn-sm" @click="checkCycles">🔍 环检测</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="exportPNG">📷 导出</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="fitContent">适应内容</button>
|
<button class="btn btn-ghost btn-sm" @click="fitContent">适应内容</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="zoomIn">+</button>
|
<button class="btn btn-ghost btn-sm" @click="zoomIn">+</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="zoomOut">-</button>
|
<button class="btn btn-ghost btn-sm" @click="zoomOut">-</button>
|
||||||
@@ -211,6 +213,43 @@ function zoomOut() {
|
|||||||
graph?.zoom(-0.1)
|
graph?.zoom(-0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 环形依赖检测。 */
|
||||||
|
const cycleNodes = ref<Set<string>>(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 {
|
function depTypeColor(depType: string): string {
|
||||||
switch (depType) {
|
switch (depType) {
|
||||||
|
|||||||
@@ -252,7 +252,10 @@ watch(searchKeyword, () => {
|
|||||||
_searchTimer = setTimeout(() => {
|
_searchTimer = setTimeout(() => {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loading.value = true
|
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)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -383,20 +386,29 @@ watch(activeProject, () => {
|
|||||||
store.setActiveTaskProject(activeProject.value)
|
store.setActiveTaskProject(activeProject.value)
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loading.value = true
|
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, () => {
|
watch(activeStatus, () => {
|
||||||
store.setActiveTaskStatus(activeStatus.value)
|
store.setActiveTaskStatus(activeStatus.value)
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loading.value = true
|
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, () => {
|
watch(sortBy, () => {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loading.value = true
|
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 搜索(桌面快捷键)
|
// Ctrl+N 新建 / Ctrl+F 搜索(桌面快捷键)
|
||||||
@@ -415,7 +427,11 @@ onMounted(async () => {
|
|||||||
store.setActiveTaskStatus(activeStatus.value)
|
store.setActiveTaskStatus(activeStatus.value)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([store.loadProjects(), store.loadTasks()])
|
await Promise.all([
|
||||||
|
store.loadProjects(),
|
||||||
|
store.loadTasks(),
|
||||||
|
taskApi.count().then(n => totalTasks.value = n),
|
||||||
|
])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user