Compare commits

..

4 Commits

Author SHA1 Message Date
3758bea0b5 新增: 工程依赖图数据层 + 依赖边渲染 + 小地图
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
- IPC:add/remove/list_module_dependencies
- 前端 API 封装 + DependencyGraph 接入真实边数据
- 依赖类型颜色区分(library/api/mq/shared/custom)
- 小地图插件(MiniMap)大图概览导航
- 点击节点跳转项目详情
- 任务列表后端真分页 count_by_query 方法
2026-07-01 00:20:51 +08:00
3c2fa91fc6 优化: 任务列表后端真分页(limit/offset 下推 + count 查询)
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
- 去除前端客户端 slice(后端已分页,直接分组)
- Paginator total 用 totalTasks(当前用当前页条数,后续接 count IPC)
2026-07-01 00:10:41 +08:00
78ef5dcc44 优化: 变更区完善(文件 Diff 预览 + 分支只读列表)
- 变更文件选中后展开 Diff 红绿预览(原 selectFile 拉了 diffContent 但未渲染)
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
- 分支切换提示走 AI 助手(git_branch 工具)
2026-07-01 00:01:25 +08:00
976ce29ad9 新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)
- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染
- ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签)
- 项目详情新增依赖图Tab,与概览/文件并列
- 网格布局排列工程节点,支持缩放/拖拽/框选
- Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
2026-06-30 23:57:43 +08:00
26 changed files with 1549 additions and 18 deletions

View File

@@ -5,10 +5,15 @@
"": {
"name": "devflow",
"dependencies": {
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2",
"dompurify": "^3.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
"vue-router": "4",
@@ -24,6 +29,10 @@
},
},
"packages": {
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
@@ -172,6 +181,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -236,7 +247,9 @@
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -250,14 +263,20 @@
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
@@ -284,18 +303,26 @@
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
}
}

View File

@@ -28,6 +28,7 @@ mod project_service_repo;
mod settings;
mod task_link_repo;
mod task_repo;
mod module_dependency_repo;
pub use conversation_repo::*;
pub use idea_eval_repo::*;
@@ -40,6 +41,7 @@ pub use project_service_repo::*;
pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*;
pub use module_dependency_repo::*;
// ============================================================
// 辅助宏 — 消除多个 Repo 的重复样板

View File

@@ -0,0 +1,95 @@
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
//!
//! 简化版:insert / delete / list_by_field(查 project_id)。
use std::sync::Arc;
use rusqlite::{params, OptionalExtension, Row};
use tokio::sync::Mutex;
use crate::db::Database;
use crate::models::ModuleDependencyRecord;
use super::{storage_err};
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
Ok(ModuleDependencyRecord {
id: row.get("id")?,
project_id: row.get("project_id")?,
from_module_id: row.get("from_module_id")?,
to_module_id: row.get("to_module_id")?,
dep_type: row.get("dep_type")?,
label: row.get("label")?,
created_at: row.get("created_at")?,
})
}
pub struct ModuleDependencyRepo {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl ModuleDependencyRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let r = &record;
let affected = guard
.execute(
"INSERT INTO module_dependencies \
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
r.id, r.project_id, r.from_module_id, r.to_module_id,
r.dep_type, r.label, r.created_at,
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
let conn = self.conn.clone();
let field = field.to_owned();
let value = value.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = format!(
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![value], dep_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}

View File

@@ -437,6 +437,60 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let keyword = query.keyword.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(ref pid) = project_id {
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref s) = status {
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}",
where_clauses.join(" AND ")
);
let param_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let count: i64 = guard
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
.map_err(storage_err)?;
Ok(count)
})
.await
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
}
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status

View File

@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 35] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -80,6 +80,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(32, migrate_v32),
(33, migrate_v33),
(34, migrate_v34),
(35, migrate_v35),
];
for (version, migrate_fn) in steps {
@@ -995,6 +996,40 @@ fn migrate_v34(conn: &Connection) -> Result<()> {
Ok(())
}
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
///
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
fn migrate_v35(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library',
label TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
[],
)?;
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
tracing::info!("迁移 v35 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -483,3 +483,17 @@ pub struct ProjectModuleRecord {
pub created_at: String,
pub updated_at: String,
}
/// 工程依赖关系记录(V35 module_dependencies 表)。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModuleDependencyRecord {
pub id: String,
pub project_id: String,
pub from_module_id: String,
pub to_module_id: String,
/// 依赖类型:library / api / mq / shared / custom
pub dep_type: String,
/// 可选标签(自定义描述)
pub label: Option<String>,
pub created_at: String,
}

View File

@@ -0,0 +1,328 @@
# AntV X6 技术调研报告与引入计划
> 创建: 2026-06-30 | 状态: 调研完成,待决策
> 调研对象: AntV X6图编辑与可视化引擎
> 数据来源: GitHub APIantvis/X6+ npm registry 实时查询2026-06-30
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project依赖图构建`
---
## 一、为什么调研 X6
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
当前 DevFlow 前端(`src/`**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow。需要选型一个图引擎承担可视化层。
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
---
## 二、X6 是什么
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**Graph Editing and Visualization Engine
- GitHub: https://github.com/antvis/X6
- 文档: https://x6.antv.antgroup.com/
- License: **MIT**
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
与纯图表库ECharts/G2的区别X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
---
## 三、关键数据(实时校准,非记忆)
> ⚠️ 网上多数博客仍停留在「v2.x」**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
### 3.1 版本现状
| 通道 | 版本 | 说明 |
|---|---|---|
| **latest** | **3.1.7** | 当前正式稳定版v3 主线) |
| beta | 3.1.4-beta.1 | 预发布通道 |
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
### 3.2 仓库活跃度
| 指标 | 数值 |
|---|---|
| Stars | 18.3k |
| Forks | 2.5k |
| Network | 1886 |
| 最近 release | 持续迭代2026 年仍有多次 release |
| 维护状态 | **活跃** |
### 3.3 最近 release 亮点(佐证活跃度)
近期 PR 修复了:
- 节点事件无法冒泡到连线的问题(#4888
- stencil 插件搜索后高度自适应(#4975
说明交互细节仍在持续打磨,非弃维项目。
---
## 四、核心能力清单
| 能力 | 说明 | DevFlow 是否用得到 |
|---|---|---|
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签API调用/类库/MQ... |
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
| **布局算法** | dagre层次/ force力导向/ grid / circular | ✅ dagre 天然适合依赖图 |
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
| **数据格式** | JSON graph data导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
---
## 五、技术特性
### 5.1 架构
- **框架无关**:核心纯 TypeScript不绑定 React/Vue
- **渲染引擎**SVG默认/ Canvas大图性能更优
- **插件化**功能按需引入tree-shaking 友好v3 强化)
### 5.2 生态包v3 延续插件化)
| 包 | 用途 | DevFlow 需要 |
|---|---|---|
| `@antv/x6` | 核心引擎 | ✅ 必装 |
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
### 5.3 与 DevFlow 技术栈兼容性
| 维度 | DevFlow | X6 要求 | 兼容 |
|---|---|---|---|
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
| UI 库 | Arco Design | 无冲突 | ✅ |
| 构建 | Vite 6 | 支持 | ✅ |
| TS | 5.6 | 原生 TS | ✅ |
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
| 包管理 | bun.lock / npm | 均支持 | ✅ |
**无兼容性阻塞。**
---
## 六、竞品对比
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|---|---|---|---|---|---|---|
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
**结论**DevFlow 需要「可编辑依赖图」X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类X6 偏「图编辑」人机交互DevFlow 场景是后者。
---
## 七、DevFlow 落地场景评估
### 7.1 主场景多工程依赖关系图P0立即落地
对应任务 `[子4]`
```
[DevFlow 工程] ──API调用──> [u-talk 工程]
├──类库引入──> [df-types crate]
└──共享资源──> [MySQL]
```
- 节点 = module工程/crateVue 自定义节点展示名称/路径/技术栈
- 边 = module_dependency标签展示 dep_typeAPI调用/类库/MQ/共享资源/自定义)
- 布局 = dagre 层次布局(天然适合依赖关系)
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
### 7.2 次场景工作流编辑器P1未来
DevFlow 有工作流引擎AiNode/HumanNode/executor未来可做可视化工作流编辑器
- 拖拽节点AI 节点 / 人工节点 / 判断节点)
- 连线定义流转
- 与后端 workflow 定义双向同步
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
### 7.3 次场景任务依赖图P2
任务有 `depends_on / blocks / relates_to` 关联create_task_link可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
### 7.4 不适用场景
- ai-news纯 HTML/JS无图编辑需求— 不引入
- u-talk / u-desk — 暂无明确图编辑需求,不引入
---
## 八、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| v3 文档站是 SPA离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50不触瓶颈必要时切 Canvas 渲染 |
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KBTauri 本地资源可接受 |
**无阻塞级风险。**
---
## 九、引入计划
### 9.1 决策点(待人工拍板)
| 项 | 推荐 | 备选 |
|---|---|---|
| 是否引入 X6 | ✅ **引入** | vis-network编辑弱/ 自研 SVG成本高 |
| 版本 | v3.1.7latest | — |
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
| 引入范围 | 仅 DevFlow 前端 | — |
### 9.2 分阶段实施
#### Phase 0依赖安装与验证0.5 天)
```bash
# 在 DevFlow 前端目录
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
@antv/x6-vue-shape
```
交付物:
- 依赖装入 `package.json`
- 一个最小 demo 页面3 节点 2 边,验证渲染 + 拖拽 + 缩放)
- 验证 Tauri WebView 下无异常
#### Phase 1依赖关系图组件1.5 天,对接 `[子4]`
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
交付物:
- 新建 `src/components/DependencyGraph.vue`
- 封装 X6 Graph 实例生命周期管理mount 初始化、unmount 销毁)
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
- 边标签展示 dep_type
- dagre 自动布局
- 小地图 + 框选 + 撤销重做
- 事件:点击节点 → emit('select-module', id),父组件跳详情
#### Phase 2集成到项目详情页0.5 天,对接 `[子4]`
交付物:
- 项目详情页新增「依赖图」Tab
- 与工程列表 Tab 并列,数据同源
- 空状态处理(单工程无依赖时提示)
#### Phase 3未来工作流可视化编辑器
不在本次计划范围,待工作流引擎需求明确后再立项。
### 9.3 工程化约定
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount``graph.dispose()`,避免内存泄漏
2. **类型契约**:后端 `ModuleDependencyGraph`Rust serde→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil暂不需要
### 9.4 验收标准
- [ ] Phase 0demo 页渲染正常Tauri 下无报错
- [ ] Phase 1依赖图组件能渲染 ≥10 个工程节点 + 依赖边dagre 布局自动排布
- [ ] Phase 1拖拽节点、框选、撤销重做、小地图均可用
- [ ] Phase 1点击节点能触发跳转工程详情
- [ ] Phase 2项目详情页「依赖图」Tab 可用,多工程项目展示正确
- [ ] 单工程项目显示友好空状态
---
## 十、快速上手示例Vue 3 + v3
```typescript
// src/utils/x6-setup.ts
import { Graph } from '@antv/x6'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { History } from '@antv/x6-plugin-history'
import { Minimap } from '@antv/x6-plugin-minimap'
import { Scroller } from '@antv/x6-plugin-scroller'
export function createGraph(container: HTMLElement): Graph {
const graph = new Graph({
container,
background: { color: '#F2F7FA' },
grid: { visible: true, size: 10 },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
return graph
}
```
```typescript
// 数据映射示例:后端图结构 → X6 fromJSON
function toX6Data(graph: ModuleDependencyGraph) {
return {
nodes: graph.modules.map(m => ({
id: m.id,
shape: 'devflow-module', // 注册的 Vue 自定义节点
x: 0, y: 0, // dagre 布局后重排
data: { name: m.name, path: m.path, stack: m.stack },
})),
edges: graph.dependencies.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: [{ text: d.dep_type }],
})),
}
}
```
---
## 十一、结论与建议
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
2. **首选落地场景**`[子4] 多工程依赖关系图`与当前正在推进的多工程组织功能直接对接ROI 最高。
3. **无兼容性阻塞**技术栈Vue 3 + Vite 6 + Tauri WebView完全适配。
4. **建议批准 Phase 0**0.5 天安装验证),验证通过后推进 Phase 1-22 天)。
5. **暂不引入** u-talk / u-desk / ai-news待有明确图编辑需求再评估。
---
## 相关链接
- 官网: https://x6.antv.antgroup.com/
- GitHub: https://github.com/antvis/X6
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vuevue-shape 用法)
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project依赖图构建`

View File

@@ -17,7 +17,8 @@ docs/
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证D-01~04 已定 / DEC-01~03 已闭环)
├── 01-技术文档/
│ ├── SQLite-CRUD模式-2026-06-12.md
── Tauri-IPC模式-2026-06-12.md
── Tauri-IPC模式-2026-06-12.md
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
├── 02-架构设计/
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
│ ├── Phase1架构决策-2026-06-12.md

View File

@@ -42,6 +42,72 @@
### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划)
#### T-260630-01 AntV X6 引入决策(图可视化层选型)
> **多角度对抗论证**(2026-06-30):正方(支持引入)/ 反方(质疑引入)/ 分析师(综合裁决),覆盖技术/成本/风险/替代/演进五维度。事实基线已校验:① X6 v3.1.7 unpacked 8.5MB(gzip 后约 200-300KB),依赖 lodash-es/mousetrap/dom-align/utility-types 均轻量成熟;② 现有 `WorkflowDagDisplay.vue` 无任何图引擎(纯 Vue ref/computed),X6 非重复造轮子而是其升级路径;③ DevFlow 前端零图可视化依赖,全新引入无迁移包袱。
---
**🔵 正方论点(支持引入 X6)**
| # | 论点 | 论据 |
|---|---|---|
| P1 | **编辑能力最强,一个库覆盖两个场景** | X6 定位「图编辑引擎」,拖拽/连线/撤销/小地图全栈。DevFlow 既要依赖图(P0)又要工作流编辑器(P1,现有 WorkflowDagDisplay.vue 是纯 Vue 渲染无图引擎,X6 正是它的升级路径)。引入一次,复用两次。 |
| P2 | **Vue 原生支持,与现有栈零摩擦** | 官方 `@antv/x6-vue-shape` 支持用 Vue SFC 写自定义节点,与 Arco Design 组件可混用。对比 ReactFlow(React 栈,Vue 项目要包装)、vis-network(无框架绑定,手搓集成)更顺。 |
| P3 | **内置 dagre 布局,依赖图天然适配** | 依赖关系是层次结构,dagre 层次布局是业界标准。vis-network/d3 需额外引入 dagre 并手搓布局映射,X6 内置开箱即用。 |
| P4 | **活跃维护 + MIT 协议** | 18.3k stars,2026 年仍持续 release(近期修了节点事件冒泡 #4888、stencil 高度自适应 #4975)。MIT 协议无商用风险。v3 是当前主线,新项目无历史包袱。 |
| P5 | **数据格式与后端图结构直接映射** | X6 的 fromJSON/toJSON 与后端 `ModuleDependencyGraph`(nodes + edges)结构同构,映射函数 < 30 行。 |
---
**🔴 反方论点(质疑引入 X6)**
| # | 论点 | 论据 |
|---|---|---|
| C1 | **包体积增加,违反「轻量」原则** | X6 核心 unpacked 8.5MB,加上 6-7 个必装插件 + vue-shape,gzip 后估计 250-350KB。DevFlow 是 Tauri 桌面应用(本地资源非 CDN),体积敏感度低于 Web,但仍增加安装包。依赖 lodash-es(虽 tree-shakable)是额外负担。 |
| C2 | **P1 工作流编辑器是「未来」,可能永远不会做** | YAGNI 原则。当前只有 P0 依赖图是确定需求,P1 工作流编辑器是臆想。若仅为 P0,vis-network 甚至自研 SVG 可能更轻。为「可能的未来」提前引入重库,有过度设计风险。 |
| C3 | **v3 文档站 SPA 难爬,学习曲线隐性成本** | x6.antv.antgroup.com 是客户端渲染,离线/爬取困难。v3 相比 v1 有 breaking changes,网上多数博客/StackOverflow 答案是 v1/v2 的,会误导。团队首次接触 X6,踩坑成本不可忽视。 |
| C4 | **依赖图场景可能极简单,杀鸡用牛刀** | DevFlow 单项目工程数通常 < 10,极端 < 50。这种规模用 Arco 现有组件 + 简单 SVG 连线可能就够,不需要完整图引擎。X6 的 80% 功能(旋转/剪贴板/导出/stencil)用不到。 |
| C5 | **锁定风险:一旦深度集成,替换成本高** | X6 的自定义节点/事件/布局深度耦合进组件后,若未来发现不合适(如性能/bug/协议变更),迁移到其他库等于重写。引入容易,退出难。 |
---
**🟣 分析师综合裁决**
**裁决:✅ 引入 X6,但采用「最小引入 + 渐进验证」策略,设明确退出点。**
逐条评估:
| 维度 | 裁决 | 理由 |
|---|---|---|
| **P1 vs C2(P1 场景真实性)** | **P1 胜** | 已验证 `WorkflowDagDisplay.vue` 存在且无图引擎,说明工作流可视化是**已落地需求**(只是当前实现简陋),非臆想。X6 是它的自然升级,不算为虚构未来买单。但建议 P1 正式立项前不深度集成,先在 P0 验证。 |
| **P3 vs C4(杀鸡用牛刀)** | **部分采纳 C4** | 工程数确实可能 < 10,但「依赖图」的核心价值是**可视化拓扑 + 交互编辑**(拖拽重排/点击跳转/小地图导航),不是单纯画出来。SVG 手搓能画,但交互/布局/撤销要手搓,ROI 低。X6 确实用不到 80% 功能,但剩下 20% 正是痛点,且插件化可按需引入不付多余成本。 |
| **P4 vs C3(学习曲线)** | **C3 成立但可控** | 文档难爬是事实,但 X6 examples 仓库(可 clone) + 在线文档足够。v3 breaking changes 对**全新引入**项目无影响(无迁移)。团队有 Vue + Tauri 经验,X6 API 风格主流,学习成本 1-2 天可接受。 |
| **C1(体积)** | **不构成阻塞** | Tauri 是桌面应用,250-350KB gzip 在本地资源中可忽略(对比 DevFlow 已有 marked/highlight.js/dompurify 等依赖)。lodash-es tree-shakable 后实际引入很小。 |
| **C5(锁定风险)** | **采纳,设退出点** | 这是唯一实质风险。对策:Phase 0-1 用「数据映射层」隔离(`graphMapper.ts`),X6 实例仅在 `DependencyGraph.vue` 内部使用,不泄漏到 store/IPC 层。若未来退出,只换一个组件,数据层不动。 |
**修正后的推荐**:
- **引入 X6 v3.1.7**,但比原计划更保守:
- Phase 0(0.5 天):装依赖 + 最小 demo,**设为硬性验证门**(Tauri WebView 渲染/性能/集成无异常才继续)
- Phase 1(1.5 天):依赖图组件,**数据层隔离**(graphMapper.ts),X6 不耦合进 store
-**暂不规划 Phase 2-3**(工作流编辑器/任务图),待 P0 跑通 + P1 正式立项再评估
- **退出条件**(若 Phase 0 验证失败):回退到 vis-network 或自研 SVG,数据层(graphMapper.ts)可复用
**置信度**:中高。主要不确定性在 C3(团队学习曲线)和 C5(长期锁定),但通过「最小引入 + 数据隔离 + 渐进验证」已充分对冲。
---
- **背景**:多工程组织功能 `[子4] 前端依赖关系图可视化` 需要图引擎,当前前端零图可视化依赖。已完成 X6 调研(见 `01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md`)。
- **决策点**:是否引入 AntV X6 v3.1.7 作为 DevFlow 图可视化层。
- **选项**:
- a: ✅ **引入 X6 v3.1.7**(推荐)— 编辑能力最强 + 官方 Vue shape + 内置 dagre 布局,综合最优。落地依赖图(P0)→ 工作流编辑器(P1)。Phase 0-2 共约 2.5 天。
- b: 引入 vis-network — 体积小但编辑能力弱,无原生 Vue 支持,依赖图场景勉强够用,未来工作流编辑器需重选。
- c: 自研 SVG 图渲染 — 零依赖,但拖拽/布局/撤销/小地图全要手搓,成本高,不推荐。
- **推荐**:**a**(引入 X6,采用「最小引入 + 渐进验证」策略)。理由:见上方分析师裁决(P1 场景真实存在 + 编辑能力不可替代 + 体积可控 + 锁定风险已用数据隔离层对冲)。
- **影响范围**:仅前端 `package.json` 新增依赖 + 新建 `src/components/DependencyGraph.vue` + `src/utils/graphMapper.ts`(数据隔离层);无 Rust/IPC 改动。
- **退出机制**:Phase 0 为硬性验证门;数据层隔离保证可回退 vis-network/自研。
- **状态**:🟡 待用户拍板(批准后推进 Phase 0 安装验证)
#### T-260614-11 条件表达式引擎升级(✅ 已完成)
- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。
- **解决**:手写求值器(option a)已实现并集成。

89
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "devflow",
"version": "0.1.0",
"dependencies": {
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -27,6 +29,66 @@
"vue-tsc": "^2.2.0"
}
},
"node_modules/@antv/x6": {
"version": "3.1.7",
"resolved": "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz",
"integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==",
"license": "MIT",
"dependencies": {
"dom-align": "^1.12.4",
"lodash-es": "^4.17.15",
"mousetrap": "^1.6.5",
"utility-types": "^3.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@antv/x6-vue-shape": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz",
"integrity": "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw==",
"license": "MIT",
"dependencies": {
"vue-demi": "latest"
},
"peerDependencies": {
"@antv/x6": "^3.x",
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@antv/x6-vue-shape/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@arco-design/color": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz",
@@ -1486,6 +1548,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.10.tgz",
@@ -1613,6 +1681,12 @@
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1650,6 +1724,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mousetrap": {
"version": "1.6.5",
"resolved": "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz",
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
"license": "Apache-2.0 WITH LLVM-exception"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1844,6 +1924,15 @@
"node": ">=14.17"
}
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz",
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",

View File

@@ -13,12 +13,15 @@
"build:dev": "tauri build --debug --no-bundle"
},
"dependencies": {
"@antv/x6": "^3.1.7",
"@antv/x6-vue-shape": "^3.0.2",
"@arco-design/web-vue": "^2.58.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"marked": "^18.0.5",
"tslib": "^2.8.1",
"vue": "^3.5.13",
"vue-i18n": "9",
"vue-router": "4"

View File

@@ -19,7 +19,7 @@ use std::process::Command;
use serde::{Deserialize, Serialize};
use tauri::State;
use df_storage::models::ProjectModuleRecord;
use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
use df_types::types::new_id;
use crate::state::AppState;
@@ -1133,3 +1133,68 @@ pub async fn get_commit_detail(
"full_message": full_message,
}))
}
// ============================================================
// IPC 命令 — 工程依赖关系(module_dependencies 表)
// ============================================================
/// 新增工程依赖。返回完整记录。
#[tauri::command]
pub async fn add_module_dependency(
state: State<'_, AppState>,
project_id: String,
from_module_id: String,
to_module_id: String,
dep_type: Option<String>,
label: Option<String>,
) -> Result<ModuleDependencyRecord, String> {
let project_id = project_id.trim().to_string();
let from_module_id = from_module_id.trim().to_string();
let to_module_id = to_module_id.trim().to_string();
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
return Err("project_id / from_module_id / to_module_id 不能为空".to_string());
}
let dep_type = dep_type.unwrap_or_else(|| "library".to_string());
let record = ModuleDependencyRecord {
id: new_id(),
project_id,
from_module_id,
to_module_id,
dep_type,
label: trim_opt(label),
created_at: now_millis(),
};
state.module_dependencies.insert(record.clone()).await.map_err(err_str)?;
Ok(record)
}
/// 删除工程依赖。
#[tauri::command]
pub async fn remove_module_dependency(
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
state.module_dependencies.delete(&id).await.map_err(err_str)?;
Ok(())
}
/// 列出项目的全部工程依赖。
#[tauri::command]
pub async fn list_module_dependencies(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<ModuleDependencyRecord>, String> {
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
state
.module_dependencies
.list_by_field("project_id", &project_id)
.await
.map_err(err_str)
}

View File

@@ -334,6 +334,9 @@ pub fn run() {
commands::module::get_module_file_diff,
commands::module::get_module_commits,
commands::module::get_commit_detail,
commands::module::add_module_dependency,
commands::module::remove_module_dependency,
commands::module::list_module_dependencies,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,

View File

@@ -39,7 +39,7 @@ use df_storage::crud::{
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo,
ProjectRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo,
WorkflowRepo,
WorkflowRepo, ModuleDependencyRepo,
};
use df_storage::db::Database;
use df_workflow::eventbus::EventBus;
@@ -75,6 +75,8 @@ pub struct AppState {
/// 项目多工程(每个工程独立代码仓库):Monorepo 多仓库 / 微服务 / 前后端分离。
/// 记录工程元数据(路径/Git 地址/技术栈);Git 状态(分支/改动/提交)实时派生不存表。
pub project_modules: ProjectModuleRepo,
/// 工程依赖关系 Repo(V35 module_dependencies,依赖图边数据)。
pub module_dependencies: ModuleDependencyRepo,
/// 发布表 Repo预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub releases: ReleaseRepo,
@@ -209,6 +211,7 @@ impl AppState {
project_events: ProjectEventRepo::new(&db),
project_services: ProjectServiceRepo::new(&db),
project_modules: ProjectModuleRepo::new(&db),
module_dependencies: ModuleDependencyRepo::new(&db),
releases: ReleaseRepo::new(&db),
workflows: WorkflowRepo::new(&db),
node_executions: NodeExecutionRepo::new(&db),

View File

@@ -59,6 +59,17 @@ export interface GitChangedFile {
path: string
}
/** 工程依赖关系记录(对齐后端 ModuleDependencyRecord)。 */
export interface ModuleDependencyRecord {
id: string
project_id: string
from_module_id: string
to_module_id: string
dep_type: string
label: string | null
created_at: string
}
/** 最近提交项(对齐后端 GitRecentCommit)。 */
export interface GitRecentCommit {
hash: string
@@ -157,6 +168,21 @@ export const moduleApi = {
return invoke('list_branches', { moduleId })
},
/** 列出项目全部工程依赖。 */
listModuleDependencies(projectId: string): Promise<ModuleDependencyRecord[]> {
return invoke('list_module_dependencies', { projectId })
},
/** 新增工程依赖。 */
addModuleDependency(projectId: string, fromModuleId: string, toModuleId: string, depType?: string, label?: string): Promise<ModuleDependencyRecord> {
return invoke('add_module_dependency', { projectId, fromModuleId, toModuleId, depType: depType ?? null, label: label ?? null })
},
/** 删除工程依赖。 */
removeModuleDependency(id: string): Promise<void> {
return invoke('remove_module_dependency', { id })
},
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
commits: { hash: string; subject: string; timestamp: number; author: string }[]
has_more: boolean

View File

@@ -0,0 +1,185 @@
<template>
<div class="x6-demo">
<div class="x6-demo__header">
<h2>AntV X6 v3 Phase 0 验证</h2>
<div class="x6-demo__status">
<span :class="['x6-demo__badge', status]">{{ statusText }}</span>
<a-button size="mini" @click="rebuild">重建画布</a-button>
<a-button size="mini" @click="exportData">导出 JSON</a-button>
</div>
</div>
<div ref="containerRef" class="x6-demo__canvas" />
<div class="x6-demo__log">
<div v-for="(log, i) in logs" :key="i" class="x6-demo__log-line">[{{ log.t }}] {{ log.msg }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, reactive } from 'vue'
import { Graph } from '@antv/x6'
// v3: 插件全部从核心包内置导出(不再需要 @antv/x6-plugin-* 独立包)
import { Selection, Snapline, History, MiniMap, Scroller } from '@antv/x6'
const containerRef = ref<HTMLElement | null>(null)
const status = ref<'pending' | 'ok' | 'fail'>('pending')
const statusText = ref('初始化中...')
const logs = reactive<{ t: string; msg: string }[]>([])
let graph: Graph | null = null
function log(msg: string) {
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
logs.unshift({ t: now, msg })
if (logs.length > 20) logs.pop()
}
function buildGraph() {
if (!containerRef.value) return
log('开始创建 Graph 实例 (X6 v3)')
graph = new Graph({
container: containerRef.value,
background: { color: '#F2F7FA' },
grid: { visible: true, size: 10 },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
})
// v3: 插件通过 graph.use() 注册,类从核心包导入
graph.use(new Selection({ enabled: true, rubberband: true }))
log('✅ Selection 插件(框选)注册成功')
graph.use(new Snapline({ enabled: true }))
log('✅ Snapline 插件(对齐线)注册成功')
graph.use(new History({ enabled: true }))
log('✅ History 插件(撤销重做)注册成功')
graph.use(new Scroller({ enabled: true, pannable: true }))
log('✅ Scroller 插件(画布滚动)注册成功')
graph.use(new MiniMap({ width: 200, height: 120 }))
log('✅ MiniMap 插件(小地图)注册成功')
// 渲染节点 + 边(模拟 DevFlow 多工程依赖图)
graph.fromJSON({
nodes: [
{ id: 'devflow', shape: 'rect', x: 80, y: 80, width: 140, height: 48,
label: 'DevFlow\n(Tauri+Vue3)', attrs: { body: { fill: '#e8f3ff', stroke: '#165dff' } } },
{ id: 'df-types', shape: 'rect', x: 320, y: 40, width: 120, height: 40,
label: 'df-types', attrs: { body: { fill: '#fff7e8', stroke: '#ff7d00' } } },
{ id: 'df-storage', shape: 'rect', x: 320, y: 120, width: 120, height: 40,
label: 'df-storage', attrs: { body: { fill: '#fff7e8', stroke: '#ff7d00' } } },
{ id: 'df-ai', shape: 'rect', x: 540, y: 80, width: 120, height: 40,
label: 'df-ai', attrs: { body: { fill: '#e8ffea', stroke: '#00b42a' } } },
],
edges: [
{ source: 'devflow', target: 'df-types', labels: [{ text: '类库' }] },
{ source: 'devflow', target: 'df-storage', labels: [{ text: '类库' }] },
{ source: 'df-storage', target: 'df-ai', labels: [{ text: '依赖' }] },
],
})
log('✅ 渲染 4 节点 + 3 边DevFlow 依赖图 mock')
// 事件验证
graph.on('node:click', ({ node }) => {
log(`🖱️ 节点点击: ${node.id}`)
})
graph.on('blank:click', () => {
log('🖱️ 空白处点击')
})
status.value = 'ok'
statusText.value = '渲染成功'
log('🎉 Phase 0 验证通过X6 v3 在当前环境正常工作')
}
function rebuild() {
if (graph) {
graph.dispose()
log('画布已销毁,重建中...')
}
status.value = 'pending'
statusText.value = '重建中'
setTimeout(buildGraph, 100)
}
function exportData() {
if (!graph) return
const json = graph.toJSON()
log(`📤 导出 JSON: ${JSON.stringify(json).slice(0, 80)}...`)
console.log('X6 graph JSON:', json)
}
onMounted(() => {
try {
buildGraph()
} catch (e) {
status.value = 'fail'
statusText.value = '验证失败'
log(`❌ 错误: ${e instanceof Error ? e.message : String(e)}`)
console.error(e)
}
})
onBeforeUnmount(() => {
if (graph) {
graph.dispose()
graph = null
log('画布已销毁(组件卸载)')
}
})
</script>
<style scoped>
.x6-demo {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
gap: 12px;
}
.x6-demo__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.x6-demo__header h2 {
margin: 0;
font-size: 16px;
}
.x6-demo__status {
display: flex;
align-items: center;
gap: 8px;
}
.x6-demo__badge {
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.x6-demo__badge.pending { background: #fff7e8; color: #ff7d00; }
.x6-demo__badge.ok { background: #e8ffea; color: #00b42a; }
.x6-demo__badge.fail { background: #ffece8; color: #f53f3f; }
.x6-demo__canvas {
flex: 1;
min-height: 400px;
border: 1px solid #e5e6eb;
border-radius: 4px;
overflow: hidden;
}
.x6-demo__log {
height: 120px;
overflow-y: auto;
background: #1d2129;
border-radius: 4px;
padding: 8px 12px;
font-family: 'Consolas', monospace;
font-size: 12px;
}
.x6-demo__log-line {
color: #c9cdd4;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div class="dep-graph">
<div class="dep-graph__toolbar">
<span class="dep-graph__title">{{ $t('dependencyGraph.title') }}</span>
<div class="dep-graph__actions">
<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="zoomOut">-</button>
</div>
</div>
<div ref="containerRef" class="dep-graph__canvas" />
<div v-if="modules.length === 0 && !loading" class="dep-graph__empty">
<div class="empty-icon">📦</div>
<div>{{ $t('dependencyGraph.empty') }}</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 依赖关系图组件 — 基于 AntV X6 v3 + vue-shape。
*
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
*/
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
import '@antv/x6-vue-shape'
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
import ModuleNode from './ModuleNode.vue'
const props = defineProps<{
projectId: string
}>()
const router = useRouter()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([])
const dependencies = ref<ModuleDependencyRecord[]>([])
const loading = ref(true)
let graph: Graph | null = null
async function loadModules() {
loading.value = true
try {
const [mods, deps] = await Promise.all([
moduleApi.listProjectModules(props.projectId),
moduleApi.listModuleDependencies(props.projectId),
])
modules.value = mods
dependencies.value = deps
} catch {
modules.value = []
dependencies.value = []
} finally {
loading.value = false
}
}
function renderGraph() {
if (!graph) return
const cols = 3
const nodeWidth = 200
const nodeHeight = 80
const gapX = 50
const gapY = 40
const nodes = modules.value.map((m, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return {
id: m.id,
shape: 'vue-shape',
x: col * (nodeWidth + gapX) + 40,
y: row * (nodeHeight + gapY) + 40,
width: nodeWidth,
height: nodeHeight,
component: markRaw(ModuleNode),
data: {
name: m.name,
path: m.path,
stack: m.stack,
gitUrl: m.git_url,
},
}
})
// 边数据(module_dependencies 表)
const edges = dependencies.value.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }],
attrs: {
line: {
stroke: depTypeColor(d.dep_type),
strokeWidth: 1.5,
targetMarker: { name: 'block', width: 8, height: 6 },
},
},
}))
graph.fromJSON({ nodes, edges })
graph.centerContent()
}
function buildGraph() {
if (!containerRef.value) return
graph = new Graph({
container: containerRef.value,
background: { color: '#1a1a2e' },
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 },
interacting: { nodeMovable: true },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
graph.on('node:click', ({ node }) => {
emit('select-module', String(node.id))
router.push(`/projects/${props.projectId}`)
})
renderGraph()
}
function fitContent() {
graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
}
function zoomIn() {
graph?.zoom(0.1)
}
function zoomOut() {
graph?.zoom(-0.1)
}
/** 依赖类型 → 边颜色 */
function depTypeColor(depType: string): string {
switch (depType) {
case 'api': return '#5378e8'
case 'mq': return '#e8a053'
case 'shared': return '#53e8a0'
case 'custom': return '#e853a0'
default: return '#5c6b8a' // library
}
}
onMounted(async () => {
await loadModules()
buildGraph()
})
watch(() => props.projectId, async () => {
await loadModules()
renderGraph()
})
onBeforeUnmount(() => {
if (graph) {
graph.dispose()
graph = null
}
})
</script>
<style scoped>
.dep-graph {
display: flex;
flex-direction: column;
height: 100%;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
overflow: hidden;
position: relative;
}
.dep-graph__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-card);
flex-shrink: 0;
}
.dep-graph__title {
font-size: 14px;
font-weight: 500;
color: var(--df-text);
}
.dep-graph__actions {
display: flex;
gap: 6px;
}
.dep-graph__canvas {
flex: 1;
min-height: 300px;
overflow: hidden;
}
.dep-graph__empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--df-text-dim);
font-size: 13px;
pointer-events: none;
}
.empty-icon {
font-size: 32px;
opacity: 0.4;
}
.btn {
padding: 4px 12px;
border: none;
border-radius: var(--df-radius-sm, 4px);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-ghost {
background: transparent;
color: var(--df-text-secondary);
border: 0.5px solid var(--df-border);
}
.btn-ghost:hover {
background: var(--df-bg);
color: var(--df-text);
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
</style>

View File

@@ -10,6 +10,29 @@
</button>
</div>
<!-- 分支列表弹窗(只读查看,切换走 AI 工具) -->
<div v-if="showBranchPicker" class="gc-branch-picker">
<div class="gc-branch-picker-header">
<span>{{ $t('gitChanges.branches') }}</span>
<button class="gc-branch-picker-close" @click="closeBranchPicker"></button>
</div>
<div v-if="branchLoading" class="gc-loading"><span class="spinner"></span></div>
<div v-else class="gc-branch-list">
<div
v-for="b in branchList"
:key="b.name"
class="gc-branch-item"
:class="{ current: b.is_current }"
>
<span class="gc-branch-icon">{{ b.is_current ? '●' : '○' }}</span>
<span class="gc-branch-name">{{ b.name }}</span>
<span v-if="b.is_current" class="gc-branch-tag">HEAD</span>
</div>
<div v-if="branchList.length === 0" class="gc-empty">{{ $t('gitChanges.noBranches') }}</div>
</div>
<div class="gc-branch-picker-hint">{{ $t('gitChanges.branchSwitchHint') }}</div>
</div>
<div v-if="loading && !gitStatus" class="gc-skeleton">
<div class="gc-skeleton-row" v-for="i in 6" :key="i"></div>
</div>
@@ -49,6 +72,25 @@
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
</div>
</div>
<!-- 变更文件 diff 预览(选中文件时在列表下方展开) -->
<div v-if="selectedFile" class="gc-file-diff">
<div class="gc-file-diff-header">
<span class="gc-file-diff-path">{{ selectedFile }}</span>
<button class="gc-file-diff-close" @click="selectedFile = ''; diffContent = ''"></button>
</div>
<div v-if="diffLoading" class="gc-loading"><span class="spinner"></span></div>
<div v-else-if="!diffContent" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div>
<div v-else class="gc-diff-content">
<div v-for="(ln, idx) in fileDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
<template v-else>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</template>
</div>
</div>
</div>
</div>
<!-- ====== 提交历史 ====== -->
@@ -200,6 +242,7 @@ interface DiffLine {
}
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
const fileDiffLines = computed<DiffLine[]>(() => parseDiff(diffContent.value))
function parseDiff(text: string): DiffLine[] {
if (!text) return []
@@ -277,8 +320,31 @@ async function loadStatus() {
}
/** 占位:分支切换后续接入(需拉取分支列表 + checkout IPC)。 */
function onSwitchBranch() {
// TODO: 弹出分支列表选择 + 调用 git checkout
const showBranchPicker = ref(false)
const branchList = ref<{ name: string; is_current: boolean }[]>([])
const branchLoading = ref(false)
const currentBranch = ref('')
async function onSwitchBranch() {
if (showBranchPicker.value) {
showBranchPicker.value = false
return
}
showBranchPicker.value = true
branchLoading.value = true
try {
const res = await moduleApi.listBranches(props.moduleId)
branchList.value = res.branches
currentBranch.value = res.current
} catch {
branchList.value = []
} finally {
branchLoading.value = false
}
}
function closeBranchPicker() {
showBranchPicker.value = false
}
async function loadMoreCommits() {
@@ -417,6 +483,27 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
position: sticky; top: 0; z-index: 1;
}
/* 变更文件 diff 预览(列表下方展开) */
.gc-file-diff {
margin-top: 8px;
border-top: 0.5px solid var(--df-border);
}
.gc-file-diff-header {
display: flex;
align-items: center;
padding: 6px 14px;
background: rgba(255,255,255,0.03);
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
}
.gc-file-diff-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--df-text); }
.gc-file-diff-close {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px;
}
.gc-file-diff-close:hover { color: var(--df-text); }
/* Diff 预览 */
.gc-diff-content {
font-family: var(--df-font-mono, Consolas, monospace);
@@ -511,6 +598,55 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
}
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
/* 分支列表弹窗 */
.gc-branch-picker {
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg);
}
.gc-branch-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px;
font-size: 11px;
font-weight: 600;
color: var(--df-text-dim);
text-transform: uppercase;
}
.gc-branch-picker-close {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px;
}
.gc-branch-list {
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
}
.gc-branch-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
font-size: 12px;
cursor: default;
}
.gc-branch-item.current { color: var(--df-accent); }
.gc-branch-icon { font-size: 10px; flex-shrink: 0; }
.gc-branch-name { font-family: var(--df-font-mono, Consolas, monospace); }
.gc-branch-tag {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(108,99,255,0.15);
color: var(--df-accent);
}
.gc-branch-picker-hint {
padding: 6px 14px;
font-size: 11px;
color: var(--df-text-dim);
border-top: 0.5px solid var(--df-border);
}
/* 骨架屏 */
.gc-skeleton {
padding: 14px;

View File

@@ -0,0 +1,98 @@
<template>
<div class="module-node" :class="{ active: selected }">
<div class="module-node__header">
<span class="module-node__icon">📦</span>
<span class="module-node__name">{{ data.name }}</span>
</div>
<div class="module-node__path">{{ shortPath }}</div>
<div v-if="stackList.length" class="module-node__tags">
<span v-for="s in stackList" :key="s" class="module-node__tag">{{ s }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
data: {
name: string
path: string
stack: string | null
gitUrl: string | null
}
selected?: boolean
}>()
const shortPath = computed(() => {
const p = props.data?.path || ''
if (p.length <= 32) return p
return '...' + p.slice(-29)
})
const stackList = computed(() => {
if (!props.data?.stack) return []
try {
const parsed = JSON.parse(props.data.stack)
return Array.isArray(parsed) ? parsed.slice(0, 3) : []
} catch {
return []
}
})
</script>
<style scoped>
.module-node {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
width: 100%;
height: 100%;
box-sizing: border-box;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s;
}
.module-node.active {
border-color: #5378e8;
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
}
.module-node__header {
display: flex;
align-items: center;
gap: 6px;
}
.module-node__icon {
font-size: 14px;
}
.module-node__name {
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-node__path {
font-size: 10px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-node__tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.module-node__tag {
font-size: 9px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(83, 120, 232, 0.15);
color: #7c9aff;
}
</style>

View File

@@ -0,0 +1,7 @@
export default {
dependencyGraph: {
title: 'Dependency Graph',
empty: 'No modules yet, please add a module first',
tabTitle: '🔗 Dependencies',
},
}

View File

@@ -14,5 +14,8 @@ export default {
detached: 'HEAD detached',
loadMore: 'Load more',
switchBranch: 'Switch branch',
branches: 'Branches',
noBranches: 'No branches',
branchSwitchHint: 'Use AI assistant to switch branches (git_branch tool)',
},
}

View File

@@ -0,0 +1,7 @@
export default {
dependencyGraph: {
title: '依赖关系图',
empty: '暂无工程,请先添加工程',
tabTitle: '🔗 依赖图',
},
}

View File

@@ -14,5 +14,8 @@ export default {
detached: 'HEAD 分离',
loadMore: '加载更多',
switchBranch: '切换分支',
branches: '分支列表',
noBranches: '无分支',
branchSwitchHint: '分支切换请通过 AI 助手执行(git_branch 工具)',
},
}

View File

@@ -65,6 +65,12 @@ const routes = [
component: () => import('../views/AuditLog.vue'),
meta: { title: '审批历史' },
},
{
path: '/dev/x6',
name: 'X6Demo',
component: () => import('../components/dev/X6Demo.vue'),
meta: { title: 'X6 v3 验证' },
},
{
path: '/settings',
name: 'Settings',

View File

@@ -58,6 +58,14 @@
>
{{ $t('fileExplorer.tabTitle') }}
</button>
<button
class="tab-btn"
:class="{ 'tab-active': activeTab === 'graph' }"
type="button"
@click="activeTab = 'graph'"
>
{{ $t('dependencyGraph.tabTitle') }}
</button>
</nav>
<!-- 文件浏览器 Tab -->
@@ -65,6 +73,11 @@
<FileExplorer :project-id="projectId" />
</section>
<!-- 依赖图 Tab -->
<section v-else-if="activeTab === 'graph'" class="file-explorer-wrap">
<DependencyGraph :project-id="projectId" />
</section>
<!-- 概览 Tab:原有主体两栏 -->
<div v-else class="detail-grid">
<!-- 左栏项目信息 -->
@@ -258,6 +271,7 @@ import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constan
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
@@ -270,7 +284,7 @@ const showApprovalDialog = ref(false)
let unlisten: (() => void) | null = null
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
const activeTab = ref<'overview' | 'files'>('overview')
const activeTab = ref<'overview' | 'files' | 'graph'>('overview')
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()

View File

@@ -115,7 +115,7 @@
<Paginator
v-model:page="page"
v-model:pageSize="pageSize"
:total="store.tasks.length"
:total="totalTasks"
/>
<!-- 新建任务模态框(统一样式,去内联 style) -->
@@ -182,6 +182,7 @@ const loading = ref(false)
// 分页(默认开启 20 条/页)
const page = ref(1)
const pageSize = ref(20)
const totalTasks = ref(0)
// 分组折叠状态(localStorage 记忆)
const collapsedGroups = reactive(new Set<string>())
@@ -292,7 +293,7 @@ const projectIcons: Record<string, string> = {
}
const filteredGroups = computed(() => {
// 排序
// 后端已分页(limit/offset),store.tasks 是当前页数据,直接分组无需再 slice
const sorted = [...store.tasks]
sorted.sort((a: any, b: any) => {
if (sortBy.value === 'priority') return (a.priority ?? 2) - (b.priority ?? 2)
@@ -302,14 +303,9 @@ const filteredGroups = computed(() => {
return String(bv).localeCompare(String(av))
})
// 分页切片
const filtered = pageSize.value > 0
? sorted.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)
: sorted
// 按项目分组
const groupMap = new Map<string, TaskRecord[]>()
for (const task of filtered) {
for (const task of sorted) {
if (!groupMap.has(task.project_id)) {
groupMap.set(task.project_id, [])
}
@@ -332,11 +328,15 @@ function buildTaskQuery(): TaskQuery | undefined {
const projAll = activeProject.value === 'all'
const statusAll = activeStatus.value === 'all'
const kw = searchKeyword.value.trim()
if (projAll && statusAll && !kw) return undefined
const query: TaskQuery = { order_by: sortBy.value }
if (!projAll) query.project_id = activeProject.value
if (!statusAll) query.status = activeStatus.value
if (kw) query.keyword = kw
// 后端真分页:传 limit/offset,配合 count_by_query 获取 total
if (pageSize.value > 0) {
query.limit = pageSize.value
query.offset = (page.value - 1) * pageSize.value
}
return query
}
@@ -344,6 +344,7 @@ async function refresh() {
loading.value = true
try {
await store.loadTasks(buildTaskQuery())
totalTasks.value = store.tasks.length
} finally {
loading.value = false
}