修复: UX扩展审查P0批(后端git/路径确定性bug + 前端UI + 编译阻断)

后端(src-tauri):

- project.rs: 4处空if目录校验补return Err(import/relocate/scan_directory/scan_project_with_ai),目录不存在不再静默放行

- ai/tools/git.rs: exec_git改返(String,bool)治git_commit·git_branch create/switch假成功 + git_merge补失败分支 + 加CREATE_NO_WINDOW

- module.rs: run_git_cmd加CREATE_NO_WINDOW,治Windows cmd黑窗闪烁(原问题9)

前端:

- DependencyGraph.vue: 环检测高亮注入renderGraph,原被fromJSON重建抹除致功能失效

- FileExplorer.vue: 工程下拉closeDropdown加closest判定,原打不开

附(预存编译阻断顺带修):

- df-nodes ai_node_helpers.rs: extract_first_json_object临时值借用悬垂E0716

- MessageList.vue: 删isLastUser死代码(vue-tsc TS6133)

审查产出: docs/05-代码审查/UIUX扩展审查-2026-08-02.md(80条发现,8高grep核验全属实)+ docs/todo.md批次段
This commit is contained in:
lxy
2026-08-02 12:19:40 +08:00
parent a28c00b1e5
commit 7f0edced01
9 changed files with 227 additions and 55 deletions
+3 -1
View File
@@ -286,7 +286,9 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
// 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。 // 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。
// 等价于 adversarial extract_json 的 (?s)\{.*\} 但用 find/rfind 零依赖实现。 // 等价于 adversarial extract_json 的 (?s)\{.*\} 但用 find/rfind 零依赖实现。
let candidates = [cleaned, extract_first_json_object(cleaned).as_str()]; // extract_first_json_object 返回 String,需先绑定变量延长生命周期,否则 .as_str() 借用悬垂(E0716)。
let fallback = extract_first_json_object(cleaned);
let candidates = [cleaned, fallback.as_str()];
for cand in candidates { for cand in candidates {
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(cand) { if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(cand) {
@@ -0,0 +1,73 @@
# DevFlow UI/UX 扩展审查报告(2026-08-02)
> **触发**:任务 `0115aea6`「总览页 + 项目列表 UI/UX 优化(持续更新)」原 12 问题清单
> **方法**:基于原 12 问题提炼 15 审查角度(A-O),workflow `wf_wynylx23h` 10 路并行扫描(6 模块深审 + 4 跨文件维度 grep),11 agent / 91 万 token / 488s
> **完整数据**:`audit_summary.json`(80 条发现)
> **核验**:主代理独立 grep 核验 8 条高严重度,**全部属实**(子代理本次无幻觉)
## 一、与原 12 问题关系
- **38 条全新**:原 12 完全未碰的模块 —— AI 对话核心区(MessageList/ChatInput/ConversationSidebar/PlanProgress 等)、工具卡审批体系(ToolCard/ApprovalPopup/DirAuthDialog)、文件浏览器(FileExplorer/FileTree/FilePreview)、AuditLog、后端 commands(task/idea/workflow/events/knowledge/services/project)、死代码
- **29+ 条延伸**:原 12 涉及文件/角度的新的具体落点(GitChanges 子串误配延伸、settings 控件延伸、Tasks 菜单延伸、git 子进程 CREATE_NO_WINDOW 延伸等)
## 二、高严重度核查(8 条 · 主代理独立 grep 核验 · 全部属实)
| # | 标题 | 位置 | 核查佐证 | 严重度 |
|---|---|---|---|---|
| 1 | project.rs 空 if 目录校验 | `L250-251 / L410-411 / L473-474 / L708-709` | ✅ **4 处空块**(原报 3 处,实 4 处),对比 L135 `create_project` 正确写法(`create_dir_all`)。import/relocate/scan 不应静默放行 | 高 |
| 2 | git_branch create/switch 吞错误 | `ai/tools/git.rs L284/L289-291` | ✅ `let _ = exec_git(...)` 恒返 `{created}/{switched_to}`,exec_git 失败返 `String::new()`(L36-48 非 Result) | 高 |
| 3 | git_commit committed 假成功 | `ai/tools/git.rs L243` | ✅ `committed: !hash.is_empty()`,commit 失败 HEAD 不动,`log -1` 仍返上次 hash | 高 |
| 4 | DependencyGraph 环检测被抹除 | `DependencyGraph.vue L227-232` | ✅ `cell.attr` 设红框后立即 `renderGraph()``fromJSON`(L175)重建节点,attr 丢失。**功能完全失效** | 高 |
| 5 | FileExplorer 工程下拉打不开 | `FileExplorer.vue L13 + L364-365` | ✅ 按钮 toggle 开 → 事件冒泡到 document `closeDropdown`(无 target 判定)立即关。**功能失效** | 高 |
| 6 | 统计源污染 state.tasks | `stores/project.ts L96` | ✅ `activeTasks: state.tasks.filter(...)` 读 Tasks 当前筛选+分页页 | 高 |
| 7 | ActiveProjectsPanel 同源 | `ActiveProjectsPanel.vue L55` | ✅ `getProjectTaskCount` 同读 `store.tasks`,与 StatCard 数字打架 | 高(与 6 合并) |
| 8 | ApprovalPopup 浮窗无计时 | `ApprovalPopup.vue L38-105` | ✅ grep `waitSecs/waitLevel` 无命中,浮窗确无挂起倒计时 | **降中**(体验,非功能;ToolCard 内联审批有,浮窗缺) |
## 三、高频主题(8)
1. **列表筛选/排序/分页/Tab 状态不持久化**(Tasks/Ideas/Knowledge/Projects/AuditLog/ProjectDetail 多处裸 ref 不入 localStorage)
2. **AuditLog 客户端筛选只作用于当前页 50 条** + hasMore 靠满页推断 + 无总数(4 路共同命中,筛选给人全局错觉)
3. **多处长内容缺折叠/截断**(AI 文本气泡/ConflictResolver 多冲突/HelpRequiredCard 错误堆栈/AuditLog brief 列/ToolResultBody 100px 过小)
4. **后端 git 工具吞错误返假成功**(exec_git 返空串非 Result,`let _=` 丢弃,branch create/switch/commit 三处)
5. **Dashboard 统计源与 Tasks 分页源共用 state.tasks**(统计卡/项目行活跃数被分页/筛选污染,两面板数字打架)
6. **审批体系三入口授权粒度不一致 + 反馈缺失**(ToolCard/ApprovalPopup 三按钮 vs DirAuthDialog 四按钮,拒绝无确认,失败无提示,浮窗无超时计时)
7. **git 子进程缺 CREATE_NO_WINDOW 散落多处**(module.rs/ai/tools/git.rs,Windows 闪窗)且 N² 放大(每次展开全仓 git status)
8. **设计 token 漂移与硬编码**(--df-primary 幽灵变量/git 状态色 3 文件重复硬编码/内联 style 散落)
## 四、实施批次规划
### 🅿 P0 确定性 bug(低风险,本会话首批 · 并行实施)
**批 1 后端(src-tauri,主代理统一 cargo check)**:
- `commands/project.rs` —— 4 处空 if 块补 `return Err`
- `commands/ai/tools/git.rs` —— exec_git 改返 `(String,bool)` 暴露 success;create/switch/commit 验证判定;顺带加 CREATE_NO_WINDOW
- `commands/module.rs` —— `run_git_cmd` 加 CREATE_NO_WINDOW(原问题 9)
**批 2 前端(主代理统一 vue-tsc)**:
- `DependencyGraph.vue` —— 环高亮注入 renderGraph(不再 attr 后 fromJSON)
- `FileExplorer.vue` —— closeDropdown 加 `closest('.module-dropdown')` 判定
- `Tasks.vue` —— 快捷菜单 document click 监听(原问题 11)
### 🅿 P1 体验/数据源(中风险,后续批)
- **数据源解耦**:`stores/project.ts` 统计 + Dashboard + ActiveProjectsPanel 改读独立 count(后端聚合或 store 全量计数),不依赖分页 state.tasks
- **长 AI 文本折叠**:MessageList 文本气泡 max-height+折叠 / ConflictResolver 多冲突折叠 / HelpRequiredCard 堆栈折叠 / ToolResultBody max-height 调大
- **AuditLog**:后端 WHERE 筛选 + 真实 total + 筛选持久化
- **列表状态持久化**:抽 `usePersistedRef` composable,Tasks/Ideas/Knowledge/Projects/ProjectDetail Tab 接入
- **审批体系一致**:ApprovalPopup 浮窗加挂起计时(H1)+ 授权粒度统一 + 拒绝二次确认 + 失败 toast
- **cmd 闪烁 N²**:FileTree 展开缓存的 git status(避免每次全仓扫描)
### 🅿 P2 一致性/打磨
- 幽灵 CSS 变量 `--df-primary` 定义(global.css + 5 处引用)
- git 变更状态色抽设计 token(3 文件硬编码)
- settings 控件统一(原问题 8 延伸:.setting-number/.setting-input/.setting-textarea)
- 死代码清理(PlanProgress.vue 全文件 / MessageList isLastUser+残留 CSS)
### 🅿 批次 I —— 原 12 问题大改(风险大,后续专项)
依赖 P1 数据源的:Dashboard 统计卡压底行(问题 1)。独立大改:Projects 列表/卡片视图(问题 2)、TaskDetail 重设计(问题 4)、IdeaDetail 重设计(问题 5)、KnowledgeDetail 重设计(问题 6)、ProjectDetail 三栏(问题 10)。埋点:project_event_repo 接入(问题 3)。
## 五、中/低严重度详单
38 中 + 34 低,按主题归组见 `audit_summary.json`(字段:highSeverity/mediumSeverity/lowSeverity,每条含 file/location/current/issue/angle/severity/novelty/suggestion)。
+21
View File
@@ -352,3 +352,24 @@ graph TD
**技能注入(待定位)**: **技能注入(待定位)**:
- [ ] **WC-技能注入-trace定位** — 技能/skill 注入链路待 trace 定位(注入时机、上下文来源、是否重复注入),定位后再决定是否待办化 - [ ] **WC-技能注入-trace定位** — 技能/skill 注入链路待 trace 定位(注入时机、上下文来源、是否重复注入),定位后再决定是否待办化
---
### 🔍 2026-08-02 UI/UX 扩展审查(wf_wynylx23h · 10 路并行 · 基于任务 0115aea6 原 12 问题角度)
> 详单:[UIUX扩展审查-2026-08-02.md](./05-代码审查/UIUX扩展审查-2026-08-02.md)。80 条发现(8 高/38 中/34 低;38 全新/29+ 延伸),高严重度主代理 grep 核验**全属实**。完整数据 `~/.claude/jobs/8ff8ccb7/tmp/audit_summary.json`。
**P0 确定性 bug(本会话首批 · 并行实施)**:
- [ ] **UX-P0-1** 后端 `commands/project.rs` 4 处空 if 目录校验(import/relocate/scan/scan_for_ai)→ `return Err`
- [ ] **UX-P0-2** 后端 `commands/ai/tools/git.rs` exec_git 吞错误假成功(create/switch/commit)→ 改返 `(String,bool)` 验证判定 + 加 CREATE_NO_WINDOW
- [ ] **UX-P0-3** 后端 `commands/module.rs` run_git_cmd 加 CREATE_NO_WINDOW(原问题 9)
- [ ] **UX-P0-4** 前端 `DependencyGraph.vue` 环检测高亮被 fromJSON 抹除 → 高亮注入 renderGraph
- [ ] **UX-P0-5** 前端 `FileExplorer.vue` 工程下拉打不开 → closeDropdown 加 `closest` 判定
- [ ] **UX-P0-6** 前端 `Tasks.vue` 快捷菜单点击外部不关闭(原问题 11)
**P1(后续批)**:数据源解耦(stores/project.ts 统计污染 H6/H7)/ 长 AI 文本折叠 / AuditLog 筛选失真+持久化 / 列表状态持久化(usePersistedRef composable)/ 审批体系一致(浮窗计时 H1+授权粒度+拒绝确认+失败反馈)/ cmd 闪烁 N² git status
**P2(打磨)**:幽灵 `--df-primary` / git 状态色 token / 控件统一(原问题 8 延伸)/ 死代码清理(PlanProgress/isLastUser)
**批次 I(原 12 大改 · 后续专项)**:Dashboard 统计卡压底行(问题 1,依赖 P1 数据源)/ Projects 视图模式(问题 2)/ TaskDetail(4)·IdeaDetail(5)·KnowledgeDetail(6)·ProjectDetail(10)重设计 / project_event_repo 埋点接入(问题 3)
+96 -28
View File
@@ -24,7 +24,11 @@ use df_storage::db::Database;
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用) // Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
// ============================================================ // ============================================================
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。 /// 在指定目录执行 git 命令(10s 超时,返回 (stdout, success))。
///
/// 返回 (stdout, success):
/// - success = child 退出码为 0(status.success());
/// - 失败时 stdout 含 stderr 内容(便于上层拼 reason);超时/启动失败 → (空串, false)。
/// ///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout /// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
@@ -33,26 +37,50 @@ use df_storage::db::Database;
/// ///
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 /// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 /// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
async fn exec_git(working_dir: &str, args: &[&str]) -> String { ///
/// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作
/// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。
/// 改返 (String, bool),上层据 success 判定真实成败。
async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) {
let mut cmd = tokio::process::Command::new("git"); let mut cmd = tokio::process::Command::new("git");
cmd.args(args) cmd.args(args)
.current_dir(working_dir) .current_dir(working_dir)
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) // 捕获 stderr 拼 reason(失败时)
.kill_on_drop(true); .kill_on_drop(true);
// Windows 屏蔽控制台窗口弹出。tokio::process::Command 在 Windows 上有 inherent
// creation_flags(无需 std::os::windows::process::CommandExt trait,故不 use)。
#[cfg(windows)]
{
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(), Ok(Ok(out)) => {
Ok(Err(_)) => String::new(), let success = out.status.success();
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止 // 失败时把 stderr 拼进 stdout 返回(上层据 success=false 读 reason)
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if success {
(stdout, true)
} else {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if stderr.is_empty() {
(stdout, false)
} else {
(stderr, false)
}
}
}
Ok(Err(_)) => (String::new(), false),
Err(_elapsed) => (String::new(), false), // 超时:child 被 kill_on_drop 终止
} }
} }
/// git status --porcelain 解析为结构化文件列表。 /// git status --porcelain 解析为结构化文件列表。
/// 返回 (当前分支, 改动文件列表 [{path, status}]) /// 返回 (当前分支, 改动文件列表 [{path, status}])
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) { async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
let branch = exec_git(working_dir, &["branch", "--show-current"]).await; let branch = exec_git(working_dir, &["branch", "--show-current"]).await.0;
let branch = branch.trim().to_string(); let branch = branch.trim().to_string();
let raw = exec_git(working_dir, &["status", "--porcelain"]).await; let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0;
let files: Vec<serde_json::Value> = raw let files: Vec<serde_json::Value> = raw
.lines() .lines()
.filter(|l| !l.is_empty()) .filter(|l| !l.is_empty())
@@ -76,12 +104,12 @@ async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut args = vec!["diff", "--stat"]; let mut args = vec!["diff", "--stat"];
if staged { args.push("--cached"); } if staged { args.push("--cached"); }
let stat_raw = exec_git(working_dir, &args).await; let stat_raw = exec_git(working_dir, &args).await.0;
// 每文件 patch(截断防 token 爆) // 每文件 patch(截断防 token 爆)
let mut patch_args = vec!["diff" ]; let mut patch_args = vec!["diff" ];
if staged { patch_args.push("--cached"); } if staged { patch_args.push("--cached"); }
let patch_raw = exec_git(working_dir, &patch_args).await; let patch_raw = exec_git(working_dir, &patch_args).await.0;
// 截断到 8000 字符(防大体量 diff) // 截断到 8000 字符(防大体量 diff)
let patch_truncated = if patch_raw.len() > 8000 { let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
@@ -99,7 +127,7 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> { async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
let format = "%H|%an|%ad|%s"; let format = "%H|%an|%ad|%s";
let limit_str = format!("-{}", limit); let limit_str = format!("-{}", limit);
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await; let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await.0;
raw.lines() raw.lines()
.filter(|l| !l.is_empty()) .filter(|l| !l.is_empty())
.filter_map(|line| { .filter_map(|line| {
@@ -223,7 +251,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let module = repo.get_by_id(module_id).await? let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝 // 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0;
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"]; let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
for line in status_raw.lines() { for line in status_raw.lines() {
let path = line.get(3..).unwrap_or("").trim().to_lowercase(); let path = line.get(3..).unwrap_or("").trim().to_lowercase();
@@ -235,15 +263,24 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
if add_all { if add_all {
let _ = exec_git(&module.path, &["add", "-A"]).await; let _ = exec_git(&module.path, &["add", "-A"]).await;
} }
// git commit // git commit(用 commit 命令的 success 判定 committed,根治假成功)
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await; let (commit_out, commit_ok) = exec_git(&module.path, &["commit", "-m", message]).await;
// 提交后取最新 commit hash // 提交后取最新 commit hash(失败时仍取,留作参考;committed 只认 commit_ok)
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string(); let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.0.trim().to_string();
// 无可提交内容时 commit 退出码非 0 + stderr 含 "nothing to commit"
let reason = if !commit_ok && commit_out.contains("nothing to commit") {
"nothing to commit, working tree clean".to_string()
} else if !commit_ok {
commit_out.trim().to_string()
} else {
String::new()
};
Ok(serde_json::json!({ Ok(serde_json::json!({
"committed": !hash.is_empty(), "committed": commit_ok,
"hash": hash, "hash": hash,
"message": message, "message": message,
"output": commit_out, "reason": reason,
"output": if commit_ok { String::new() } else { commit_out },
})) }))
} }
); );
@@ -271,8 +308,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
match action { match action {
"list" => { "list" => {
let raw = exec_git(&module.path, &["branch", "--list"]).await; let raw = exec_git(&module.path, &["branch", "--list"]).await.0;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string();
let branches: Vec<String> = raw.lines() let branches: Vec<String> = raw.lines()
.map(|l| l.trim_start_matches("* ").trim().to_string()) .map(|l| l.trim_start_matches("* ").trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@@ -281,14 +318,38 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} }
"create" => { "create" => {
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); } if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
let _ = exec_git(&module.path, &["branch", name]).await; let (msg, ok) = exec_git(&module.path, &["branch", name]).await;
Ok(serde_json::json!({ "created": name })) if ok {
Ok(serde_json::json!({ "created": true, "name": name }))
} else {
Ok(serde_json::json!({
"created": false,
"name": name,
"reason": msg.trim(),
}))
}
} }
"switch" => { "switch" => {
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); } if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
let _ = exec_git(&module.path, &["checkout", name]).await; let (_, checkout_ok) = exec_git(&module.path, &["checkout", name]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string();
Ok(serde_json::json!({ "switched_to": current })) if checkout_ok && current == name {
Ok(serde_json::json!({
"switched": true,
"current": current,
}))
} else {
Ok(serde_json::json!({
"switched": false,
"current": current,
"name": name,
"reason": if current == name {
"checkout 未改变分支".to_string()
} else {
format!("切换失败,当前仍在 {}", if current.is_empty() { "(空)" } else { &current })
},
}))
}
} }
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action), _ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
} }
@@ -315,10 +376,10 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectModuleRepo::new(&db); let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await? let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let merge_out = exec_git(&module.path, &["merge", branch]).await; let (merge_out, merge_ok) = exec_git(&module.path, &["merge", branch]).await;
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict"); let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
if has_conflict { if has_conflict {
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await; let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await.0;
let conflicts: Vec<String> = status_raw.lines() let conflicts: Vec<String> = status_raw.lines()
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD")) .filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
.map(|l| l.get(3..).unwrap_or("").trim().to_string()) .map(|l| l.get(3..).unwrap_or("").trim().to_string())
@@ -328,12 +389,19 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
"conflicts": conflicts, "conflicts": conflicts,
"message": "合并冲突,需手动解决", "message": "合并冲突,需手动解决",
})) }))
} else { } else if merge_ok {
Ok(serde_json::json!({ Ok(serde_json::json!({
"merged": true, "merged": true,
"branch": branch, "branch": branch,
"output": merge_out, "output": merge_out,
})) }))
} else {
// 非冲突但 merge 退出码非 0(如 fast-forward 失败/被中断),如实暴露失败
Ok(serde_json::json!({
"merged": false,
"branch": branch,
"reason": merge_out.trim(),
}))
} }
} }
); );
+11 -4
View File
@@ -467,13 +467,20 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
let cwd = cwd.to_path_buf(); let cwd = cwd.to_path_buf();
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>(); let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
std::thread::spawn(move || { std::thread::spawn(move || {
let out = Command::new("git") let mut cmd = Command::new("git");
.args(&args) cmd.args(&args)
.current_dir(&cwd) .current_dir(&cwd)
.env("LANG", "en_US.UTF-8") .env("LANG", "en_US.UTF-8")
.env("LC_ALL", "en_US.UTF-8") .env("LC_ALL", "en_US.UTF-8")
.env("GIT_PAGER", "cat") .env("GIT_PAGER", "cat");
.output(); // Windows 下 spawn 子进程默认会弹 cmd 黑窗,加 CREATE_NO_WINDOW 抑制闪烁
// (GitChanges 挂载并发跑 rev-parse/status/log,不抑制会疯狂闪)。
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output();
let _ = tx.send(out); let _ = tx.send(out);
}); });
+4
View File
@@ -248,6 +248,7 @@ pub async fn import_project(
return Err("导入路径不能为空".to_string()); return Err("导入路径不能为空".to_string());
} }
if !Path::new(&path).is_dir() { if !Path::new(&path).is_dir() {
return Err(format!("目录不存在: {}", path));
} }
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。 // 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
@@ -408,6 +409,7 @@ pub async fn relocate_project_path(
new_path: String, new_path: String,
) -> Result<ProjectRecord, String> { ) -> Result<ProjectRecord, String> {
if !Path::new(&new_path).is_dir() { if !Path::new(&new_path).is_dir() {
return Err(format!("目录不存在: {}", new_path));
} }
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? { if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name)); return Err(format!("目录已被项目「{}」绑定", conflict.name));
@@ -471,6 +473,7 @@ pub async fn scan_directory_for_projects(
) -> Result<Vec<ScannedProjectItem>, String> { ) -> Result<Vec<ScannedProjectItem>, String> {
let root = Path::new(&root_path); let root = Path::new(&root_path);
if !root.is_dir() { if !root.is_dir() {
return Err(format!("目录不存在: {}", root_path));
} }
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime) // 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
@@ -706,6 +709,7 @@ pub async fn scan_project_with_ai(
) -> Result<AiScanResult, String> { ) -> Result<AiScanResult, String> {
let root = Path::new(&path); let root = Path::new(&path);
if !root.is_dir() { if !root.is_dir() {
return Err(format!("目录不存在: {}", path));
} }
// 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime) // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)
-10
View File
@@ -95,16 +95,6 @@ function shouldRenderMsg(msg: AiMessage): boolean {
return true return true
} }
/** UX-09:msg 是否为 messages 中最后一条 user 消息(可编辑判定)。 */
function isLastUser(msg: AiMessage): boolean {
if (msg.role !== 'user') return false
const msgs = store.state.messages
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') return msgs[i].id === msg.id
}
return false
}
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */ /** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
function formatTokens(n: number): string { function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
+14 -10
View File
@@ -63,6 +63,7 @@ import { useRouter } from 'vue-router'
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6' import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
import dagre from 'dagre' import dagre from 'dagre'
import '@antv/x6-vue-shape' import '@antv/x6-vue-shape'
import { Message } from '@arco-design/web-vue'
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module' import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
import ModuleNode from './ModuleNode.vue' import ModuleNode from './ModuleNode.vue'
@@ -141,6 +142,7 @@ function renderGraph() {
// X6 // X6
const nodes = modules.value.map(m => { const nodes = modules.value.map(m => {
const pos = g.node(m.id) const pos = g.node(m.id)
const isCycle = cycleNodes.value.has(m.id)
return { return {
id: m.id, id: m.id,
shape: 'vue-shape', shape: 'vue-shape',
@@ -155,6 +157,11 @@ function renderGraph() {
stack: m.stack, stack: m.stack,
gitUrl: m.git_url, gitUrl: m.git_url,
}, },
// ();renderGraph ,,
// graph.fromJSON
attrs: isCycle ? {
body: { stroke: '#e05050', strokeWidth: 3 },
} : undefined,
} }
}) })
@@ -218,20 +225,17 @@ const cycleNodes = ref<Set<string>>(new Set())
async function checkCycles() { async function checkCycles() {
try { try {
const cycles = await moduleApi.detectModuleCycles(props.projectId) const cycles = await moduleApi.detectModuleCycles(props.projectId)
// id, renderGraph ( cell.attr fromJSON )
cycleNodes.value = new Set(cycles) 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() renderGraph()
if (cycles.length > 0) {
Message.warning(`检测到 ${cycles.length} 个环节点(已红框高亮)`)
} else {
Message.success('未检测到环形依赖')
}
} catch (e) { } catch (e) {
console.error('[DependencyGraph] 环检测失败:', e) console.error('[DependencyGraph] 环检测失败:', e)
Message.error('环检测失败')
} }
} }
+5 -2
View File
@@ -360,8 +360,11 @@ function onChangeFileSelect(path: string) {
watch(() => props.projectId, loadModules, { immediate: true }) watch(() => props.projectId, loadModules, { immediate: true })
// // ( .module-dropdown , toggle )
function closeDropdown() { moduleDropdownOpen.value = false } function closeDropdown(e: Event) {
if ((e.target as HTMLElement)?.closest('.module-dropdown')) return
moduleDropdownOpen.value = false
}
onMounted(() => document.addEventListener('click', closeDropdown)) onMounted(() => document.addEventListener('click', closeDropdown))
onUnmounted(() => document.removeEventListener('click', closeDropdown)) onUnmounted(() => document.removeEventListener('click', closeDropdown))