diff --git a/apps/df-miniapp/package-lock.json b/apps/df-miniapp/package-lock.json index 6f842c6..13b4064 100644 --- a/apps/df-miniapp/package-lock.json +++ b/apps/df-miniapp/package-lock.json @@ -22,6 +22,7 @@ "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", + "@swc/helpers": "^0.5.23", "@types/node": "^20.0.0", "@vue/runtime-core": "^3.4.0", "@vue/tsconfig": "^0.5.0", @@ -5056,6 +5057,16 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz", @@ -12995,6 +13006,13 @@ "node": ">=8" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", diff --git a/apps/df-miniapp/package.json b/apps/df-miniapp/package.json index 91f10e5..24faedf 100644 --- a/apps/df-miniapp/package.json +++ b/apps/df-miniapp/package.json @@ -7,7 +7,7 @@ "dev:h5": "uni", "dev:mp-weixin": "uni -p mp-weixin", "build:h5": "uni build", - "build:mp-weixin": "uni build -p mp-weixin", + "build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs", "type-check": "vue-tsc --noEmit" }, "dependencies": { @@ -25,6 +25,7 @@ "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", + "@swc/helpers": "^0.5.23", "@types/node": "^20.0.0", "@vue/runtime-core": "^3.4.0", "@vue/tsconfig": "^0.5.0", diff --git a/apps/df-miniapp/scripts/fix-mp-build.cjs b/apps/df-miniapp/scripts/fix-mp-build.cjs new file mode 100644 index 0000000..182a75e --- /dev/null +++ b/apps/df-miniapp/scripts/fix-mp-build.cjs @@ -0,0 +1,69 @@ +//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题 +//! +//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?{try{return!!new RegExp("(?<=1)(?\{try\{return!!new RegExp\("\(\?<=1\)\(\?`+)[^`]+\k` 匹配代码围栏。 + // 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。 + // 通用: (?...) → (...), \k → \N(按捕获组序编号,这里只有 a/b 两组) + src = src.replace(/\(\?\`\+\)\[^\`\]\+\\k/g, '(`+)\\1') + src = src.replace(/\(\?\`\+\)\[^\`\]\+\\k/g, '(`+)\\1') + // 兜底:任何残留命名捕获组 (? 或 \k 转为无命名 + src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(') + src = src.replace(/\\k<[a-z]>/g, '\\1') + if (src !== before) { + fs.writeFileSync(VENDOR, src) + console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)') + } else { + console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)') + } +} + +// 清理手动补的 @swc/helpers(不再需要,避免污染) +function cleanupSwcHelpers() { + const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers') + if (fs.existsSync(swcDir)) { + fs.rmSync(swcDir, { recursive: true, force: true }) + console.log('[fix-mp-build] 已清理手动补的 @swc/helpers') + } +} + +fixLookbehind() +cleanupSwcHelpers() diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index 4d7d773..27e51fe 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -13,7 +13,7 @@ //! //! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 //! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, -//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径( +//! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径( //! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 use std::sync::Arc; @@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{ AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, }; use crate::adversarial_helpers::{ - SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval, - priority_label, + SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt, + enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level, }; /// 对抗评估引擎 @@ -97,8 +97,9 @@ impl AdversarialEngine { /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 /// - /// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与 - /// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。 + /// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`] + /// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时, + /// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。 async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc) -> Result { let prompt = build_adversarial_prompt(idea); // 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 @@ -134,7 +135,9 @@ impl AdversarialEngine { // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1], // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 - parse_llm_eval(&resp.text, &idea.id) + // ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation / + // analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。 + Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?)) } /// 启发式评估(基于评分与内容信号,稳定有区分度) @@ -145,7 +148,7 @@ impl AdversarialEngine { let positive = self.generate_positive_argument(idea, &scores)?; let negative = self.generate_negative_argument(idea, &scores)?; let analyst = self.analyst_analysis(idea, &scores)?; - let recommendation = self.recommendation_for(&analyst.final_assessment); + let recommendation = recommendation_for_level(&analyst.final_assessment); Ok(AdversarialEval { idea_id: idea.id.clone(), @@ -240,13 +243,8 @@ impl AdversarialEngine { /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result { - let final_assessment = match scores.overall { - x if x >= 7.5 => AssessmentLevel::StrongGo, - x if x >= 6.0 => AssessmentLevel::Recommended, - x if x >= 4.5 => AssessmentLevel::Conditional, - x if x >= 3.0 => AssessmentLevel::Revised, - _ => AssessmentLevel::Defer, - }; + // 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移 + let final_assessment = assessment_for_score(scores.overall); let mut strengths = Vec::new(); if scores.impact >= 6.0 { @@ -305,22 +303,12 @@ impl AdversarialEngine { final_assessment, }) } - - /// 评估等级 → 最终建议 - fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation { - match level { - AssessmentLevel::StrongGo => Recommendation::ImmediateAction, - AssessmentLevel::Recommended => Recommendation::Soon, - AssessmentLevel::Conditional => Recommendation::WithResources, - AssessmentLevel::Revised => Recommendation::ResearchMore, - AssessmentLevel::Defer => Recommendation::Monitor, - } - } } #[cfg(test)] mod tests { use super::*; + use crate::adversarial_helpers::recommendation_for_score; use crate::capture::Idea; use crate::scoring::ScoringEngine; use df_types::types::{IdeaStatus, Priority}; @@ -594,4 +582,78 @@ mod tests { assert!((eval.final_score - 7.0).abs() < 1e-9); assert_eq!(eval.recommendation, Recommendation::Soon); } + + /// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正 + /// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。 + #[tokio::test] + async fn a13_llm_inconsistent_high_score_corrected() { + let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]); + let llm_text = r#"{ + "positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8}, + "negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3}, + "analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"}, + "final_score": 8.5, + "recommendation": "Monitor" +}"#; + let provider = Arc::new(MockProvider { text: llm_text.to_string() }); + let engine = AdversarialEngine::new(provider); + let eval = engine.evaluate(&idea).await.unwrap(); + + println!("\n[a13] 高分配 Monitor/Defer → 修正"); + println!(" final_score={:.2} (高分)", eval.final_score); + println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation); + println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment); + + assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级"); + assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正"); + assert_eq!(eval.recommendation, Recommendation::ImmediateAction); + assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo); + } + + /// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。 + #[tokio::test] + async fn a14_llm_inconsistent_low_score_corrected() { + let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]); + let llm_text = r#"{ + "positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9}, + "negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1}, + "analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"}, + "final_score": 2.0, + "recommendation": "ImmediateAction" +}"#; + let provider = Arc::new(MockProvider { text: llm_text.to_string() }); + let engine = AdversarialEngine::new(provider); + let eval = engine.evaluate(&idea).await.unwrap(); + + println!("\n[a14] 低分配 ImmediateAction/StrongGo → 修正"); + println!(" final_score={:.2} (低分)", eval.final_score); + println!(" recommendation={:?} (期望 Monitor)", eval.recommendation); + println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment); + + assert_eq!(eval.evaluated_by, EvaluatedBy::Llm); + assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正"); + assert_eq!(eval.recommendation, Recommendation::Monitor); + assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer); + } + + /// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。 + #[test] + fn consistency_banding_matches_heuristic() { + assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo); + assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含 + assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended); + assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended); + assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional); + assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional); + assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised); + assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised); + assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer); + assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer); + + assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction); + assert_eq!(recommendation_for_score(6.5), Recommendation::Soon); + assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources); + assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore); + assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor); + } } diff --git a/crates/df-ideas/src/adversarial_helpers.rs b/crates/df-ideas/src/adversarial_helpers.rs index f1aebc3..0580882 100644 --- a/crates/df-ideas/src/adversarial_helpers.rs +++ b/crates/df-ideas/src/adversarial_helpers.rs @@ -1,8 +1,9 @@ //! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。 //! -//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用 -//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与 -//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。 +//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造、JSON 解析与自洽性校验 +//! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm +//! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在 +//! `adversarial.rs`,二者经 [`use`] 互相引用。 //! //! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部 //! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等) @@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result { } } +// ============================================================ +// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段 +// ============================================================ + +/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。 +pub(crate) fn recommendation_for_level(level: &AssessmentLevel) -> Recommendation { + match level { + AssessmentLevel::StrongGo => Recommendation::ImmediateAction, + AssessmentLevel::Recommended => Recommendation::Soon, + AssessmentLevel::Conditional => Recommendation::WithResources, + AssessmentLevel::Revised => Recommendation::ResearchMore, + AssessmentLevel::Defer => Recommendation::Monitor, + } +} + +/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。 +pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel { + if score >= 7.5 { + AssessmentLevel::StrongGo + } else if score >= 6.0 { + AssessmentLevel::Recommended + } else if score >= 4.5 { + AssessmentLevel::Conditional + } else if score >= 3.0 { + AssessmentLevel::Revised + } else { + AssessmentLevel::Defer + } +} + +/// 综合评分 → 最终建议(先分档再映射)。 +pub(crate) fn recommendation_for_score(score: f64) -> Recommendation { + recommendation_for_level(&assessment_for_score(score)) +} + +/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment +/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction),parse 只校验枚举合法性不校验语义。 +/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等 +/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。 +pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval { + let expected_rec = recommendation_for_score(eval.final_score); + let expected_level = assessment_for_score(eval.final_score); + if eval.recommendation != expected_rec { + tracing::warn!( + score = eval.final_score, + from = ?eval.recommendation, + to = ?expected_rec, + "LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定" + ); + eval.recommendation = expected_rec; + } + if eval.analyst.final_assessment != expected_level { + tracing::warn!( + score = eval.final_score, + from = ?eval.analyst.final_assessment, + to = ?expected_level, + "LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定" + ); + eval.analyst.final_assessment = expected_level; + } + eval +} + pub(crate) fn priority_label(p: &Priority) -> &'static str { match p { Priority::Critical => "紧急", diff --git a/crates/df-nodes/src/script_node.rs b/crates/df-nodes/src/script_node.rs index 9bb60b3..6be3145 100644 --- a/crates/df-nodes/src/script_node.rs +++ b/crates/df-nodes/src/script_node.rs @@ -6,7 +6,7 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; /// 脚本节点 pub struct ScriptNode; -// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ── +// ── 运行时白/黑名单(通过 set_script_safety_config 注入,优先于 env var;env var 作回退) ── use std::sync::OnceLock; /// 运行时白名单配置(前端设置页写入,优先于环境变量) diff --git a/crates/df-types/src/types.rs b/crates/df-types/src/types.rs index 2a15dfd..d6ba3ad 100644 --- a/crates/df-types/src/types.rs +++ b/crates/df-types/src/types.rs @@ -484,6 +484,22 @@ impl Priority { Priority::Critical => "critical", } } + + /// 从 i32 解析优先级(对齐**前端约定** 0=Critical/1=High/2=Medium/3=Low, + /// 与 src-tauri commands::idea::priority_from_i32 兜底语义一致,统一调用入口)。 + /// + /// 注意:enum discriminant 与前端相反(Low=0/Critical=3,历史定义), + /// 勿用 `p as Priority` 转换——本函数手动映射前端语义,与 `as_str` 序列化 + /// ("low"/"critical") 互不干扰。越界/负值(含 4+、-1 等)统一归并 Low。 + pub fn from_i32(p: i32) -> Priority { + match p { + 0 => Priority::Critical, + 1 => Priority::High, + 2 => Priority::Medium, + // 3(低)与越界/负值统一归 Low,对齐现有 priority_from_i32 读侧语义 + _ => Priority::Low, + } + } } impl Default for Priority { @@ -503,7 +519,7 @@ pub fn new_id() -> String { #[cfg(test)] mod tests { - use super::TaskStatus; + use super::{Priority, TaskStatus}; // 合法值:覆盖全部枚举变体(与 as_str 一致) #[test] @@ -592,4 +608,33 @@ mod tests { // 数量应等于枚举变体数 assert_eq!(TaskStatus::valid_values().len(), 7); } + + // ── Priority::from_i32(对齐前端约定 0=Critical/1=High/2=Medium/3=Low)── + + #[test] + fn priority_from_i32_maps_frontend_semantics() { + assert_eq!(Priority::from_i32(0), Priority::Critical); + assert_eq!(Priority::from_i32(1), Priority::High); + assert_eq!(Priority::from_i32(2), Priority::Medium); + assert_eq!(Priority::from_i32(3), Priority::Low); + } + + // 越界/负值统一归并 Low(对齐 src-tauri priority_from_i32 的 `_` 兜底) + #[test] + fn priority_from_i32_out_of_range_falls_back_to_low() { + assert_eq!(Priority::from_i32(4), Priority::Low); + assert_eq!(Priority::from_i32(99), Priority::Low); + assert_eq!(Priority::from_i32(-1), Priority::Low); + assert_eq!(Priority::from_i32(i32::MIN), Priority::Low); + assert_eq!(Priority::from_i32(i32::MAX), Priority::Low); + } + + // 与 as_str 对偶自洽:from_i32 的每个前端值 round-trip 后 as_str 不变 + #[test] + fn priority_from_i32_roundtrip_consistent_with_as_str() { + assert_eq!(Priority::from_i32(0).as_str(), "critical"); + assert_eq!(Priority::from_i32(1).as_str(), "high"); + assert_eq!(Priority::from_i32(2).as_str(), "medium"); + assert_eq!(Priority::from_i32(3).as_str(), "low"); + } } diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index e3beff1..a8d1b0c 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -71,6 +71,7 @@ pub(crate) async fn ensure_conversation_title( prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, + is_estimated: None, timestamp: m.timestamp, }) .collect(); diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 1e6af46..fa4a241 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -474,7 +474,7 @@ pub async fn get_module_git_status( Ok(serde_json::to_value(status).map_err(err_str)?) } -/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 +/// 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。 #[tauri::command] pub async fn list_branches( state: State<'_, AppState>, @@ -497,10 +497,15 @@ pub async fn list_branches( let dir = module.path.clone(); // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 let result = tokio::task::spawn_blocking(move || -> (String, Vec) { - // git branch --format="%(HEAD)%00%(refname:short)" + // git branch -a --format="%(HEAD)%00%(refname:short)%00%(refname)" + // -a 同时列出本地分支(refs/heads/*)与远程跟踪分支(refs/remotes/*,短名形如 origin/main); + // 原仅 `git branch` 只列本地分支,远程分支被排除(仓库只见一个分支的根因)。 let out = run_git_cmd( std::path::Path::new(&dir), - &["branch", "--format=%(HEAD)%00%(refname:short)"], + &[ + "branch", "-a", + "--format=%(HEAD)%00%(refname:short)%00%(refname)", + ], std::time::Duration::from_secs(10), ) .unwrap_or_default(); @@ -509,10 +514,16 @@ pub async fn list_branches( for line in out.lines() { let line = line.trim(); if line.is_empty() { continue; } - // 格式: "*\0branch_name" 或 "\0branch_name" - let (head, name) = line.split_once('\u{0}').unwrap_or(("", line)); + // 格式: "*\0branch_name\0refs/...",三字段(HEAD 标记 / 短名 / 完整 ref)。 + let parts: Vec<&str> = line.split('\u{0}').collect(); + if parts.len() < 2 { continue; } + let (head, short) = (parts[0], parts[1]); + // 跳过 remote HEAD 伪分支(refs/remotes//HEAD → 短名即裸 remote 名如 origin, + // 指向远端默认分支,非真实分支,列出会误导)。 + let full = parts.get(2).copied().unwrap_or(""); + if full.ends_with("/HEAD") { continue; } let is_current = head.contains('*'); - let name = name.trim().to_string(); + let name = short.trim().to_string(); if name.is_empty() { continue; } if is_current { current = name.clone(); } branches.push(serde_json::json!({ "name": name, "is_current": is_current })); @@ -655,6 +666,30 @@ const NOISE_DIRS: &[&str] = &[ ".vite", ]; +/// 判断目录是否含可见子项(过滤隐藏文件/噪音目录后,与 `get_module_file_tree` 展开实际展示一致)。 +/// +/// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录, +/// 不必等用户展开才发现是空的。`read_dir` 首个子项命中即短路返回,开销为 O(可见项数)。 +fn dir_has_visible_children(dir: &Path) -> bool { + let Ok(read) = std::fs::read_dir(dir) else { + // 目录不可读(权限等)按空处理,避免误导为有内容 + return false; + }; + read.flatten().any(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + // 与列目录过滤保持一致:隐藏文件 + 噪音目录不算可见子项 + if name.starts_with('.') { + return false; + } + if NOISE_DIRS.iter().any(|n| *n == name.as_ref()) { + return false; + } + // 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见 + entry.file_type().is_ok() + }) +} + /// 文件树条目(单层;前端点击文件夹再懒加载下一层)。 #[derive(Debug, Serialize)] struct FileTreeEntry { @@ -668,6 +703,9 @@ struct FileTreeEntry { /// 文件夹恒为 None(只标文件)。 #[serde(skip_serializing_if = "Option::is_none")] git_status: Option, + /// 目录是否含可见子项(过滤隐藏/噪音目录后,与展开实际展示一致)。 + /// 文件恒为 false;目录在列目录时即时统计。前端未展开时行内标注空目录用。 + has_children: bool, } /// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。 @@ -771,12 +809,18 @@ pub async fn get_module_file_tree( } else { git_map_clone.get(&posix_rel).cloned() }; + let has_children = if is_dir { + dir_has_visible_children(&entry.path()) + } else { + false + }; items.push(FileTreeEntry { name, path: posix_rel, is_dir, size, git_status, + has_children, }); } // 目录优先,各自字母序(稳定可预期,前端无需再排)。 diff --git a/src/api/module.ts b/src/api/module.ts index 60b6fa3..1a5e16d 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -36,6 +36,9 @@ export interface FileTreeEntry { size: number /** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */ git_status?: string + /** 目录是否含可见子项(过滤隐藏文件/噪音目录后,与展开实际展示一致);文件恒为 false。 + * 后端列目录时即时统计,前端未展开即可据此行内标注空目录。 */ + has_children: boolean } /** getModuleFileTree 返回结构。 */ @@ -177,7 +180,7 @@ export const moduleApi = { }, /** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */ - /** 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 */ + /** 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。 */ listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> { return invoke('list_branches', { moduleId }) }, diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index da598b2..c6594ec 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -80,9 +80,9 @@ * * 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。 */ -import { onMounted, onBeforeUnmount, ref, watch, computed, markRaw } from 'vue' +import { onMounted, onBeforeUnmount, onActivated, ref, watch, computed, markRaw } from 'vue' import { useI18n } from 'vue-i18n' -import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6' +import { Graph, Selection, Snapline, History, Scroller, MiniMap, Export } from '@antv/x6' import dagre from 'dagre' import { register } from '@antv/x6-vue-shape' import '@antv/x6-vue-shape' @@ -261,13 +261,38 @@ function renderGraph(opts?: { center?: boolean }) { if (shouldCenter) graph.centerContent() } +/** + * F4:局部更新节点选中态(替代整图 fromJSON 重建,保留平移/缩放态)。 + * 只刷新被点节点 + 上次选中节点两个 cell:更新其 data.selected,并手动触发该 node view 的 + * 'vue' action 重挂载 Vue 组件。原因:vue-shape 下 node.setData 不触发组件重渲染 + * (NodeView/VueShapeView 的 actions 映射无 data→render,'component' 变化才触发 'vue' action)。 + */ +function updateNodeSelection(nextId: string) { + if (!graph) return + const prevId = selectedId.value + selectedId.value = nextId + const ids = new Set() + if (nextId) ids.add(nextId) + if (prevId) ids.add(prevId) + for (const id of ids) { + const cell = graph.getCellById(id) + if (!cell) continue + const d = cell.getData() ?? {} + cell.setData({ ...d, selected: id === nextId }) + // 'vue' 不在库导出 FlagManagerAction 联合类型内(vue-shape 扩展),故以 any 访问。 + const view = graph.findViewByCell(cell) as any + if (view?.confirmUpdate) view.confirmUpdate(view.getFlag('vue')) + } +} + 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' } }, + // U1:画布/grid 用主题 token 实际色值(X6 background/grid 需具体色值,非 CSS 变量),深色硬编码移除。 + background: { color: themeColor('--df-bg', '#0c0e1a') }, + grid: { visible: true, size: 10, type: 'dot', args: { color: themeColor('--df-border-strong', '#2a2a4e') } }, mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 }, interacting: { nodeMovable: true }, }) @@ -277,18 +302,31 @@ function buildGraph() { graph.use(new History({ enabled: true })) graph.use(new Scroller({ enabled: true, pannable: true })) graph.use(new MiniMap({ width: 200, height: 120, padding: 10 })) + // F3:注册 Export 插件。X6 的 toPNG 依赖 graph.use(new Export()) 挂载 'export' 插件, + // 缺注册时 Graph.prototype.toPNG 静默 no-op(回调永不触发、无报错),导出按钮失效。 + graph.use(new Export()) graph.on('node:click', ({ node }) => { // 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系, // 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。 - selectedId.value = String(node.id) - renderGraph({ center: false }) + // F4:局部刷新选中态(仅被点节点+上次选中节点),不再整图 fromJSON 重建,保留平移/缩放态。 + updateNodeSelection(String(node.id)) }) renderGraph() bindEdgeDeleteButtons() } +/** + * U1:读取 CSS 主题 token 的实际色值(X6 画布 background/grid 需要具体颜色字符串,不支持 CSS 变量)。 + * 取不到时回退传入的默认色值。buildGraph 在挂载后执行,此时 data-theme 已生效,读到当前主题色。 + */ +function themeColor(varName: string, fallback: string): string { + if (typeof window === 'undefined') return fallback + const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() + return v || fallback +} + function fitContent() { graph?.zoomToFit({ padding: 20, maxScale: 1.5 }) } @@ -320,19 +358,25 @@ async function checkCycles() { } } -/** 导出 PNG。 */ -async function exportPNG() { +/** + * 导出 PNG。 + * F3:toPNG 回调是异步触发,外层 try/catch 捕不到回调内错误;且 X6 的 toPNGAsync 在导出失败时 + * 吞错永不 resolve(会挂死),故沿用回调风格,错误处理放入回调体(try/catch 包下载逻辑), + * 失败 console.error + Message.error 用户可见提示。 + */ +function exportPNG() { if (!graph) return - try { - graph.toPNG((dataUri: string) => { + graph.toPNG((dataUri: string) => { + try { const a = document.createElement('a') a.href = dataUri a.download = `dependency-graph-${props.projectId}.png` a.click() - }) - } catch (e) { - console.error('[DependencyGraph] 导出 PNG 失败:', e) - } + } catch (e) { + console.error('[DependencyGraph] 导出 PNG 失败:', e) + Message.error(t('common.unknownError')) + } + }) } /** 删除依赖(二次确认 + 重绘)。 */ @@ -384,12 +428,15 @@ function hideEdgeDeleteBtn() { deleteDepTarget = null } +// F2:edge hover 处理器提升为 setup 作用域稳定函数引用(非每次 bind 新建对象), +// 使 graph.off 能删掉旧 handler,避免 edge:mouseenter/leave 监听累积导致 showEdgeDeleteBtn 重复触发。 +function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) } +function onEdgeLeave() { hideEdgeDeleteBtn() } + /** 给所有 edge 绑 hover 显示删除按钮的监听。 */ function bindEdgeDeleteButtons() { if (!graph) return try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ } - function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) } - function onEdgeLeave() { hideEdgeDeleteBtn() } graph.on('edge:mouseenter', onEdgeEnter) graph.on('edge:mouseleave', onEdgeLeave) } @@ -410,6 +457,18 @@ onMounted(async () => { buildGraph() }) +// KeepAlive 保活:本组件被 KeepAlive 包裹不重挂载,缺 onActivated 时切回依赖图 tab 不刷新(F1)。 +// 切回 tab 时重新拉模块+依赖并重绘(覆盖概览页增删模块后切回的场景)。 +// 首次挂载 KeepAlive 激活也会触发,此时 onMounted 已 loadModules+renderGraph,守卫跳过避免重复拉。 +let activatedOnce = false +onActivated(() => { + if (!activatedOnce) { + activatedOnce = true + return + } + void loadModules().then(() => renderGraph()) +}) + watch(() => props.projectId, async () => { await loadModules() renderGraph() diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index 3db1c58..c6d866f 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -129,6 +129,7 @@ :file-path="selectedFilePath" :module-root-path="currentModule?.path ?? ''" :git-status="selectedFileGitStatus" + :external-diff="selectedCommitDiff" />
{{ $t('fileExplorer.selectFileHint') }}
@@ -252,6 +253,8 @@ const expandedPaths = reactive(new Set()) const loadedChildren = reactive(new Map()) const selectedFilePath = ref(null) const selectedFileGitStatus = ref(undefined) +/** GitChanges(变更/提交历史)点文件注入的 diff:传给右侧 FilePreview 在宽区渲染(历史提交的 diff 优先)。 */ +const selectedCommitDiff = ref('') const currentModule = computed( () => modules.value.find((m) => m.id === currentModuleId.value) ?? null, @@ -302,6 +305,7 @@ function resetTreeState() { loadedChildren.clear() selectedFilePath.value = null selectedFileGitStatus.value = undefined + selectedCommitDiff.value = '' } /** 自定义下拉选择工程。 */ @@ -317,6 +321,8 @@ function onFileSelect(path: string) { selectedFilePath.value = path // 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。 selectedFileGitStatus.value = findEntryGitStatus(path) + // 树视图选中 → 回到内容预览,不残留 GitChanges 注入的 diff + selectedCommitDiff.value = '' } /** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */ @@ -351,6 +357,7 @@ async function refresh() { loadedChildren.clear() selectedFilePath.value = null selectedFileGitStatus.value = undefined + selectedCommitDiff.value = '' // 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。 await new Promise((r) => setTimeout(r, 50)) refreshing.value = false @@ -402,10 +409,11 @@ async function switchToChanges() { } } -/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */ -function onChangeFileSelect(path: string) { +/** Git 变更视图中选择文件 → 在右侧预览显示 diff(变化/提交历史点文件都走此路,宽区渲染)。 */ +function onChangeFileSelect(path: string, diff?: string) { selectedFilePath.value = path selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status + selectedCommitDiff.value = diff ?? '' } watch(() => props.projectId, loadModules, { immediate: true }) @@ -761,6 +769,7 @@ async function onRemoveModule() { .explorer-tree { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 8px 4px 8px 0; diff --git a/src/components/project/FilePreview.vue b/src/components/project/FilePreview.vue index eb75838..553bbb2 100644 --- a/src/components/project/FilePreview.vue +++ b/src/components/project/FilePreview.vue @@ -39,28 +39,28 @@
{{ $t('fileExplorer.selectFileHint') }}
- -
+ +
{{ $t('fileExplorer.loadingFile') }}
- -
⚠ {{ error }}
+ +
⚠ {{ error }}
- -
+ +
📄

{{ $t('fileExplorer.binaryNotSupported') }}

- -
+ +
- -
+ +
{{ ln.oldNum || '' }} {{ ln.newNum || '' }} @@ -98,6 +98,9 @@ const props = defineProps<{ filePath: string | null moduleRootPath: string gitStatus?: string + /** 外部注入的 diff(如 GitChanges 变更/提交历史点文件得到的该文件 diff,含历史提交的 diff)。 + * 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求。 */ + externalDiff?: string }>() const { } = useMarkdown() @@ -127,6 +130,17 @@ const showDiff = ref(false) const diffContent = ref('') const diffLoading = ref(false) +/** 是否存在外部注入的 diff(非空即视为外部 diff 模式:默认展示 diff,且 diff 优先级最高)。 */ +const isExternalDiff = computed(() => !!props.externalDiff && props.externalDiff.trim().length > 0) + +/** 实际展示的 diff 内容:外部注入优先,否则用本组件拉取的 git diff。 */ +const diffViewContent = computed(() => (isExternalDiff.value ? props.externalDiff! : diffContent.value)) + +/** 外部 diff 注入时默认切到 diff 视图(点文件即看变化,无需再点 📝);清空则回到内容视图。 */ +watch(() => props.externalDiff, (val) => { + showDiff.value = !!(val && val.trim().length > 0) +}) + /** 请求序号守卫:loadFile/loadDiff 各自单调递增,await 返回后校验仍是"最新 seq"才写状态, * 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。 * 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile @@ -152,11 +166,11 @@ function parseHunkHeader(line: string): { oldStart: number; newStart: number } { } const diffLines = computed(() => { - if (!diffContent.value) return [] + if (!diffViewContent.value) return [] const lines: DiffLine[] = [] let oldNum = 0 let newNum = 0 - for (const raw of diffContent.value.split('\n')) { + for (const raw of diffViewContent.value.split('\n')) { if (raw.startsWith('@@')) { const h = parseHunkHeader(raw) oldNum = h.oldStart @@ -180,7 +194,8 @@ const diffLines = computed(() => { function toggleDiff() { showDiff.value = !showDiff.value - if (showDiff.value && !diffContent.value) { + // 外部 diff 已由调用方提供,无需再发 git diff;仅内部模式在首次切到 diff 时才拉取 + if (showDiff.value && !diffContent.value && !isExternalDiff.value) { loadDiff() } } diff --git a/src/components/project/FileTree.vue b/src/components/project/FileTree.vue index 903ba0a..05602a0 100644 --- a/src/components/project/FileTree.vue +++ b/src/components/project/FileTree.vue @@ -13,8 +13,11 @@
⚠ {{ error }}
- -
{{ $t('fileExplorer.emptyDir') }}
+ +
+ 📭 + {{ $t('fileExplorer.emptyDir') }} +
    @@ -39,6 +42,13 @@ {{ expandedPaths.has(entry.path) ? '▾' : '▸' }} 📁 {{ entry.name }} + + {{ $t('fileExplorer.emptyDirMark') }}
@@ -172,8 +153,9 @@ /** * Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。 * - * 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染, - * 避免几百行 diff 把列表挤走、或在 30% 窄栏内挤压。 + * 布局:变更 Tab 点文件 / 提交历史点文件 → emit select-file,把该文件 diff 注入右侧 FilePreview + * 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff —— 几百行 diff + * 在 30% 窄栏内渲染会被挤压/截断,故 diff 展示统一走右侧宽区。 */ import { ref, computed, watch } from 'vue' import { moduleApi, type GitStatusResult } from '@/api/module' @@ -188,7 +170,8 @@ const props = defineProps<{ }>() const emit = defineEmits<{ - (e: 'select-file', path: string): void + /** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */ + (e: 'select-file', path: string, diff?: string): void }>() const loading = ref(false) @@ -231,8 +214,6 @@ const selectedCommit = ref(null) const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null) const commitDetailLoading = ref(false) const commitSelectedFile = ref('') -const commitDiff = ref('') -const commitDiffCollapsed = ref(false) const branchName = computed(() => gitStatus.value?.branch || '') @@ -271,33 +252,6 @@ const groupedChanges = computed(() => { return groups.filter(g => g.files.length > 0) }) -interface DiffLine { - type: 'add' | 'del' | 'ctx' | 'hdr' - prefix: string - text: string -} - -const commitDiffLines = computed(() => parseDiff(commitDiff.value)) - -function parseDiff(text: string): DiffLine[] { - if (!text) return [] - const lines: DiffLine[] = [] - for (const raw of text.split('\n')) { - if (raw.startsWith('@@')) { - lines.push({ type: 'hdr', prefix: '', text: raw }) - } else if (raw.startsWith('+')) { - lines.push({ type: 'add', prefix: '+', text: raw.slice(1) }) - } else if (raw.startsWith('-')) { - lines.push({ type: 'del', prefix: '-', text: raw.slice(1) }) - } else if (raw.startsWith('\\')) { - continue - } else { - lines.push({ type: 'ctx', prefix: ' ', text: raw }) - } - } - return lines -} - function statusLabel(s: string): string { const x = s.trim() if (x === '??') return 'U' @@ -478,6 +432,7 @@ async function switchToHistory() { async function selectFile(path: string) { selectedFile.value = path + // 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化 emit('select-file', path) diffContent.value = '' diffLoading.value = true @@ -489,6 +444,10 @@ async function selectFile(path: string) { } finally { diffLoading.value = false } + // 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件 + if (selectedFile.value === path) { + emit('select-file', path, diffContent.value) + } } /** 点击提交行 → 加载该提交的变更文件列表 */ @@ -501,8 +460,8 @@ async function selectCommit(c: CommitItem) { selectedCommit.value = c commitDetail.value = null commitSelectedFile.value = '' - commitDiff.value = '' - commitDiffCollapsed.value = false + // 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位 + emit('select-file', '', '') commitDetailLoading.value = true try { const res = await moduleApi.getCommitDetail(props.moduleId, c.hash) @@ -518,18 +477,17 @@ function closeCommitDetail() { selectedCommit.value = null commitDetail.value = null commitSelectedFile.value = '' - commitDiff.value = '' + emit('select-file', '', '') } -/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */ +/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */ function showCommitFileDiff(path: string) { commitSelectedFile.value = path - commitDiffCollapsed.value = false if (!commitDetail.value) { - commitDiff.value = '' + emit('select-file', path, '') return } - commitDiff.value = extractFileDiff(commitDetail.value.diff, path) + emit('select-file', path, extractFileDiff(commitDetail.value.diff, path)) } watch(() => props.moduleId, loadStatus, { immediate: true }) @@ -599,23 +557,7 @@ watch(() => props.refreshKey, () => { position: sticky; top: 0; z-index: 1; } -/* Diff 预览 */ -.gc-diff-content { - font-family: var(--df-font-mono, Consolas, monospace); - font-size: 12px; line-height: 1.5; overflow: auto; scrollbar-width: thin; -} -.diff-line { display: flex; padding: 0 14px; } -.diff-line-prefix { width: 16px; flex-shrink: 0; text-align: center; user-select: none; } -.diff-line-text { flex: 1; white-space: pre; overflow: hidden; } -.diff-hdr-text { - padding: 4px 14px; background: rgba(60,140,220,0.08); - color: var(--df-text-dim); font-weight: 500; font-size: 11px; display: block; -} -.diff-add { background: rgba(40,160,70,0.12); } -.diff-add .diff-line-prefix { color: #4caf50; } -.diff-del { background: rgba(220,60,60,0.12); } -.diff-del .diff-line-prefix { color: #e05050; } -.diff-ctx { color: var(--df-text); } +/* Diff 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */ /* ====== 提交历史 ====== */ .gc-history { display: flex; flex-direction: column; min-height: 0; } @@ -714,31 +656,6 @@ watch(() => props.refreshKey, () => { .gc-commit-file-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-file-row.active { background: rgba(255,255,255,0.06); } -/* 提交 diff(可收起,占更宽空间) */ -.gc-commit-diff { - flex: 1; min-height: 0; - display: flex; flex-direction: column; - overflow: hidden; -} -.gc-commit-diff-header { - display: flex; align-items: center; gap: 8px; - padding: 5px 14px; background: rgba(255,255,255,0.03); - border-bottom: 0.5px solid var(--df-border); flex-shrink: 0; -} -.gc-commit-diff-path { - flex: 1; min-width: 0; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--df-font-mono, Consolas, monospace); font-size: 11px; - color: var(--df-text); -} -.gc-commit-diff-toggle { - background: none; border: 0.5px solid var(--df-border); - color: var(--df-text-dim); cursor: pointer; font-size: 11px; - padding: 2px 8px; border-radius: 4px; -} -.gc-commit-diff-toggle:hover { color: var(--df-text); border-color: var(--df-accent); } -.gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; } - /* 滑出动画 */ .gc-detail-slide-enter-active, .gc-detail-slide-leave-active { transition: transform 0.18s ease, opacity 0.18s ease; @@ -851,8 +768,6 @@ watch(() => props.refreshKey, () => { padding: 40px 0; color: var(--df-text-dim); font-size: 13px; } .gc-empty-inline { padding: 24px 0; } -.gc-diff-loading { display: flex; justify-content: center; padding: 20px; } -.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; } .spinner { width: 16px; height: 16px; border: 2px solid var(--df-border); border-top-color: var(--df-accent); diff --git a/src/components/project/ModuleNode.vue b/src/components/project/ModuleNode.vue index 40b44b5..f3611db 100644 --- a/src/components/project/ModuleNode.vue +++ b/src/components/project/ModuleNode.vue @@ -66,21 +66,21 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3)) width: 100%; height: 100%; box-sizing: border-box; - background: #16213e; - border: 1px solid #0f3460; + background: var(--df-bg-card); + border: 1px solid var(--df-border-strong); 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); + border-color: var(--df-accent); + box-shadow: 0 0 0 2px var(--df-accent-soft); } /* 环节点红框高亮(G2):vue-shape markup 无 body 选择器,原 attrs.body 渲染无效, 改由依赖图把 isCycle 注入节点 data,组件根元素按类描边。 */ .module-node--cycle { - border-color: #e05050; - box-shadow: 0 0 0 2px rgba(224, 80, 80, 0.3); + border-color: var(--df-danger); + box-shadow: 0 0 0 2px var(--df-danger-bg); } .module-node__header { display: flex; @@ -93,14 +93,14 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3)) .module-node__name { font-size: 13px; font-weight: 600; - color: #e0e0e0; + color: var(--df-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .module-node__path { font-size: 10px; - color: #888; + color: var(--df-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -114,7 +114,7 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3)) font-size: 9px; padding: 1px 6px; border-radius: 4px; - background: rgba(83, 120, 232, 0.15); - color: #7c9aff; + background: var(--df-accent-bg); + color: var(--df-accent); } diff --git a/src/i18n/en/fileExplorer.ts b/src/i18n/en/fileExplorer.ts index 6c73d7a..ac02c25 100644 --- a/src/i18n/en/fileExplorer.ts +++ b/src/i18n/en/fileExplorer.ts @@ -10,6 +10,7 @@ export default { // File tree loading: 'Loading…', emptyDir: 'Empty directory', + emptyDirMark: 'empty', // File preview selectFileHint: '← Select a file on the left to preview', loadingFile: 'Loading file…', diff --git a/src/i18n/zh-CN/fileExplorer.ts b/src/i18n/zh-CN/fileExplorer.ts index de2418b..1134185 100644 --- a/src/i18n/zh-CN/fileExplorer.ts +++ b/src/i18n/zh-CN/fileExplorer.ts @@ -10,6 +10,7 @@ export default { // 文件树 loading: '加载中…', emptyDir: '空目录', + emptyDirMark: '空', // 文件预览 selectFileHint: '← 选择左侧文件查看预览', loadingFile: '加载文件中…', diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 53a72d3..d033673 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -1256,7 +1256,7 @@ onUnmounted(() => {