diff --git a/crates/df-nodes/src/ai_node_helpers.rs b/crates/df-nodes/src/ai_node_helpers.rs index 0cdab39..b980c8b 100644 --- a/crates/df-nodes/src/ai_node_helpers.rs +++ b/crates/df-nodes/src/ai_node_helpers.rs @@ -286,7 +286,9 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value { // 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。 // 等价于 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 { if let Ok(mut v) = serde_json::from_str::(cand) { diff --git a/docs/05-代码审查/UIUX扩展审查-2026-08-02.md b/docs/05-代码审查/UIUX扩展审查-2026-08-02.md new file mode 100644 index 0000000..43a4ec6 --- /dev/null +++ b/docs/05-代码审查/UIUX扩展审查-2026-08-02.md @@ -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)。 diff --git a/docs/todo.md b/docs/todo.md index 910fd3f..34d299f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -352,3 +352,24 @@ graph TD **技能注入(待定位)**: - [ ] **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) diff --git a/src-tauri/src/commands/ai/tools/git.rs b/src-tauri/src/commands/ai/tools/git.rs index cd4f7fb..ba8830c 100644 --- a/src-tauri/src/commands/ai/tools/git.rs +++ b/src-tauri/src/commands/ai/tools/git.rs @@ -24,7 +24,11 @@ use df_storage::db::Database; // 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 /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, @@ -33,26 +37,50 @@ use df_storage::db::Database; /// /// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 /// 被 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"); cmd.args(args) .current_dir(working_dir) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) // 捕获 stderr 拼 reason(失败时) .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 { - Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(), - Ok(Err(_)) => String::new(), - Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止 + Ok(Ok(out)) => { + let success = out.status.success(); + // 失败时把 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 解析为结构化文件列表。 /// 返回 (当前分支, 改动文件列表 [{path, status}]) async fn run_git_status(working_dir: &str) -> (String, Vec) { - 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 raw = exec_git(working_dir, &["status", "--porcelain"]).await; + let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0; let files: Vec = raw .lines() .filter(|l| !l.is_empty()) @@ -76,12 +104,12 @@ async fn run_git_status(working_dir: &str) -> (String, Vec) { async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { let mut args = vec!["diff", "--stat"]; 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 爆) let mut patch_args = vec!["diff" ]; 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) let patch_truncated = if patch_raw.len() > 8000 { 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 { let format = "%H|%an|%ad|%s"; 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() .filter(|l| !l.is_empty()) .filter_map(|line| { @@ -223,7 +251,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; // 敏感文件检查:提交前查看待提交文件列表,如有 .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"]; for line in status_raw.lines() { let path = line.get(3..).unwrap_or("").trim().to_lowercase(); @@ -235,15 +263,24 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { if add_all { let _ = exec_git(&module.path, &["add", "-A"]).await; } - // git commit - let commit_out = exec_git(&module.path, &["commit", "-m", message]).await; - // 提交后取最新 commit hash - let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string(); + // git commit(用 commit 命令的 success 判定 committed,根治假成功) + let (commit_out, commit_ok) = exec_git(&module.path, &["commit", "-m", message]).await; + // 提交后取最新 commit hash(失败时仍取,留作参考;committed 只认 commit_ok) + 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!({ - "committed": !hash.is_empty(), + "committed": commit_ok, "hash": hash, "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) { .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; match action { "list" => { - let raw = exec_git(&module.path, &["branch", "--list"]).await; - let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); + let raw = exec_git(&module.path, &["branch", "--list"]).await.0; + let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string(); let branches: Vec = raw.lines() .map(|l| l.trim_start_matches("* ").trim().to_string()) .filter(|s| !s.is_empty()) @@ -281,14 +318,38 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { } "create" => { if name.is_empty() { anyhow::bail!("create 需要 name 参数"); } - let _ = exec_git(&module.path, &["branch", name]).await; - Ok(serde_json::json!({ "created": name })) + let (msg, ok) = exec_git(&module.path, &["branch", name]).await; + if ok { + Ok(serde_json::json!({ "created": true, "name": name })) + } else { + Ok(serde_json::json!({ + "created": false, + "name": name, + "reason": msg.trim(), + })) + } } "switch" => { if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); } - let _ = exec_git(&module.path, &["checkout", name]).await; - let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string(); - Ok(serde_json::json!({ "switched_to": current })) + let (_, checkout_ok) = exec_git(&module.path, &["checkout", name]).await; + let current = exec_git(&module.path, &["branch", "--show-current"]).await.0.trim().to_string(); + 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 { ¤t }) + }, + })) + } } _ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action), } @@ -315,10 +376,10 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .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"); 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 = status_raw.lines() .filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD")) .map(|l| l.get(3..).unwrap_or("").trim().to_string()) @@ -328,12 +389,19 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { "conflicts": conflicts, "message": "合并冲突,需手动解决", })) - } else { + } else if merge_ok { Ok(serde_json::json!({ "merged": true, "branch": branch, "output": merge_out, })) + } else { + // 非冲突但 merge 退出码非 0(如 fast-forward 失败/被中断),如实暴露失败 + Ok(serde_json::json!({ + "merged": false, + "branch": branch, + "reason": merge_out.trim(), + })) } } ); diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index b2403a9..a2d1944 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -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 args = args.iter().map(|s| s.to_string()).collect::>(); std::thread::spawn(move || { - let out = Command::new("git") - .args(&args) + let mut cmd = Command::new("git"); + cmd.args(&args) .current_dir(&cwd) .env("LANG", "en_US.UTF-8") .env("LC_ALL", "en_US.UTF-8") - .env("GIT_PAGER", "cat") - .output(); + .env("GIT_PAGER", "cat"); + // 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); }); diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index c5437dc..f2f6233 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -248,6 +248,7 @@ pub async fn import_project( return Err("导入路径不能为空".to_string()); } if !Path::new(&path).is_dir() { + return Err(format!("目录不存在: {}", path)); } // 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。 @@ -408,6 +409,7 @@ pub async fn relocate_project_path( new_path: String, ) -> Result { 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? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); @@ -471,6 +473,7 @@ pub async fn scan_directory_for_projects( ) -> Result, String> { let root = Path::new(&root_path); if !root.is_dir() { + return Err(format!("目录不存在: {}", root_path)); } // 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime) @@ -706,6 +709,7 @@ pub async fn scan_project_with_ai( ) -> Result { let root = Path::new(&path); if !root.is_dir() { + return Err(format!("目录不存在: {}", path)); } // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime) diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 0ef5ba4..163cef2 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -95,16 +95,6 @@ function shouldRenderMsg(msg: AiMessage): boolean { 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) */ function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index e5e4bcc..5dd8eff 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -63,6 +63,7 @@ import { useRouter } from 'vue-router' import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6' import dagre from 'dagre' import '@antv/x6-vue-shape' +import { Message } from '@arco-design/web-vue' import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module' import ModuleNode from './ModuleNode.vue' @@ -141,6 +142,7 @@ function renderGraph() { // 映射回 X6 节点格式 const nodes = modules.value.map(m => { const pos = g.node(m.id) + const isCycle = cycleNodes.value.has(m.id) return { id: m.id, shape: 'vue-shape', @@ -155,6 +157,11 @@ function renderGraph() { stack: m.stack, gitUrl: m.git_url, }, + // 环节点高亮(红框);renderGraph 是画布唯一渲染入口,高亮必须在这里注入, + // 否则会被 graph.fromJSON 整体替换抹除。 + attrs: isCycle ? { + body: { stroke: '#e05050', strokeWidth: 3 }, + } : undefined, } }) @@ -218,20 +225,17 @@ const cycleNodes = ref>(new Set()) async function checkCycles() { try { const cycles = await moduleApi.detectModuleCycles(props.projectId) + // 收集环节点 id,交给 renderGraph 统一渲染高亮(避免 cell.attr 被 fromJSON 抹除)。 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() + if (cycles.length > 0) { + Message.warning(`检测到 ${cycles.length} 个环节点(已红框高亮)`) + } else { + Message.success('未检测到环形依赖') + } } catch (e) { console.error('[DependencyGraph] 环检测失败:', e) + Message.error('环检测失败') } } diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index b52af8f..9df9e6d 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -360,8 +360,11 @@ function onChangeFileSelect(path: string) { watch(() => props.projectId, loadModules, { immediate: true }) -// 点击外部关闭工程下拉 -function closeDropdown() { moduleDropdownOpen.value = false } +// 点击外部关闭工程下拉(点在 .module-dropdown 内不关,否则按钮 toggle 会被冒泡回调立即关闭) +function closeDropdown(e: Event) { + if ((e.target as HTMLElement)?.closest('.module-dropdown')) return + moduleDropdownOpen.value = false +} onMounted(() => document.addEventListener('click', closeDropdown)) onUnmounted(() => document.removeEventListener('click', closeDropdown))