优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理
This commit is contained in:
Generated
+18
@@ -22,6 +22,7 @@
|
|||||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||||
|
"@swc/helpers": "^0.5.23",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@vue/runtime-core": "^3.4.0",
|
"@vue/runtime-core": "^3.4.0",
|
||||||
"@vue/tsconfig": "^0.5.0",
|
"@vue/tsconfig": "^0.5.0",
|
||||||
@@ -5056,6 +5057,16 @@
|
|||||||
"@sinonjs/commons": "^1.7.0"
|
"@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": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
|
||||||
@@ -12995,6 +13006,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev:h5": "uni",
|
"dev:h5": "uni",
|
||||||
"dev:mp-weixin": "uni -p mp-weixin",
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
"build:h5": "uni build",
|
"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"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||||
|
"@swc/helpers": "^0.5.23",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@vue/runtime-core": "^3.4.0",
|
"@vue/runtime-core": "^3.4.0",
|
||||||
"@vue/tsconfig": "^0.5.0",
|
"@vue/tsconfig": "^0.5.0",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
|
||||||
|
//!
|
||||||
|
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
|
||||||
|
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
|
||||||
|
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
|
||||||
|
//!
|
||||||
|
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
|
||||||
|
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
|
||||||
|
//! 同时保留 marked 完整功能(运行时本就降级)。
|
||||||
|
//!
|
||||||
|
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
|
||||||
|
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
|
||||||
|
|
||||||
|
function fixLookbehind() {
|
||||||
|
if (!fs.existsSync(VENDOR)) {
|
||||||
|
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let src = fs.readFileSync(VENDOR, 'utf8')
|
||||||
|
const before = src
|
||||||
|
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
|
||||||
|
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
|
||||||
|
src = src.replace(
|
||||||
|
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
|
||||||
|
'var Te=false,'
|
||||||
|
)
|
||||||
|
if (src === before) {
|
||||||
|
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
|
||||||
|
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
|
||||||
|
}
|
||||||
|
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
|
||||||
|
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
|
||||||
|
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
|
||||||
|
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
|
||||||
|
// 替换后行为不变,同时消除微信工具的静态触发器。
|
||||||
|
src = src.replace(/\(\?<!`\)/g, '(?:x)')
|
||||||
|
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
|
||||||
|
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
|
||||||
|
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
|
||||||
|
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
|
||||||
|
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
|
||||||
|
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
|
||||||
|
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
|
||||||
|
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()
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
|
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
|
||||||
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
|
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
|
||||||
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
|
//! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
|
||||||
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
|
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
|
|||||||
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
|
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
|
||||||
};
|
};
|
||||||
use crate::adversarial_helpers::{
|
use crate::adversarial_helpers::{
|
||||||
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
|
SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
|
||||||
priority_label,
|
enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 对抗评估引擎
|
/// 对抗评估引擎
|
||||||
@@ -97,8 +97,9 @@ impl AdversarialEngine {
|
|||||||
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
|
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
|
||||||
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
|
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
|
||||||
///
|
///
|
||||||
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
|
/// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
|
||||||
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
|
/// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
|
||||||
|
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
|
||||||
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
||||||
let prompt = build_adversarial_prompt(idea);
|
let prompt = build_adversarial_prompt(idea);
|
||||||
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
||||||
@@ -134,7 +135,9 @@ impl AdversarialEngine {
|
|||||||
|
|
||||||
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
|
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1],
|
||||||
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-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 positive = self.generate_positive_argument(idea, &scores)?;
|
||||||
let negative = self.generate_negative_argument(idea, &scores)?;
|
let negative = self.generate_negative_argument(idea, &scores)?;
|
||||||
let analyst = self.analyst_analysis(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 {
|
Ok(AdversarialEval {
|
||||||
idea_id: idea.id.clone(),
|
idea_id: idea.id.clone(),
|
||||||
@@ -240,13 +243,8 @@ impl AdversarialEngine {
|
|||||||
|
|
||||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
||||||
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
||||||
let final_assessment = match scores.overall {
|
// 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
|
||||||
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
let final_assessment = assessment_for_score(scores.overall);
|
||||||
x if x >= 6.0 => AssessmentLevel::Recommended,
|
|
||||||
x if x >= 4.5 => AssessmentLevel::Conditional,
|
|
||||||
x if x >= 3.0 => AssessmentLevel::Revised,
|
|
||||||
_ => AssessmentLevel::Defer,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut strengths = Vec::new();
|
let mut strengths = Vec::new();
|
||||||
if scores.impact >= 6.0 {
|
if scores.impact >= 6.0 {
|
||||||
@@ -305,22 +303,12 @@ impl AdversarialEngine {
|
|||||||
final_assessment,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::adversarial_helpers::recommendation_for_score;
|
||||||
use crate::capture::Idea;
|
use crate::capture::Idea;
|
||||||
use crate::scoring::ScoringEngine;
|
use crate::scoring::ScoringEngine;
|
||||||
use df_types::types::{IdeaStatus, Priority};
|
use df_types::types::{IdeaStatus, Priority};
|
||||||
@@ -594,4 +582,78 @@ mod tests {
|
|||||||
assert!((eval.final_score - 7.0).abs() < 1e-9);
|
assert!((eval.final_score - 7.0).abs() < 1e-9);
|
||||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
|
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
|
||||||
//!
|
//!
|
||||||
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造与 JSON 解析等无副作用
|
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造、JSON 解析与自洽性校验
|
||||||
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
|
//! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
|
||||||
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
|
//! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
|
||||||
|
//! `adversarial.rs`,二者经 [`use`] 互相引用。
|
||||||
//!
|
//!
|
||||||
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
|
//! 抽离边界:类型定义(非 impl)+ 常量 + 顶层 free function。impl 主体不动,外部
|
||||||
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
|
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
|
||||||
@@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 自洽性校验(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 {
|
pub(crate) fn priority_label(p: &Priority) -> &'static str {
|
||||||
match p {
|
match p {
|
||||||
Priority::Critical => "紧急",
|
Priority::Critical => "紧急",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
|||||||
/// 脚本节点
|
/// 脚本节点
|
||||||
pub struct ScriptNode;
|
pub struct ScriptNode;
|
||||||
|
|
||||||
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
|
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,优先于 env var;env var 作回退) ──
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
|
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
|
||||||
|
|||||||
@@ -484,6 +484,22 @@ impl Priority {
|
|||||||
Priority::Critical => "critical",
|
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 {
|
impl Default for Priority {
|
||||||
@@ -503,7 +519,7 @@ pub fn new_id() -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::TaskStatus;
|
use super::{Priority, TaskStatus};
|
||||||
|
|
||||||
// 合法值:覆盖全部枚举变体(与 as_str 一致)
|
// 合法值:覆盖全部枚举变体(与 as_str 一致)
|
||||||
#[test]
|
#[test]
|
||||||
@@ -592,4 +608,33 @@ mod tests {
|
|||||||
// 数量应等于枚举变体数
|
// 数量应等于枚举变体数
|
||||||
assert_eq!(TaskStatus::valid_values().len(), 7);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
prompt_cache_hit_tokens: None,
|
prompt_cache_hit_tokens: None,
|
||||||
prompt_cache_miss_tokens: None,
|
prompt_cache_miss_tokens: None,
|
||||||
reasoning_tokens: None,
|
reasoning_tokens: None,
|
||||||
|
is_estimated: None,
|
||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ pub async fn get_module_git_status(
|
|||||||
Ok(serde_json::to_value(status).map_err(err_str)?)
|
Ok(serde_json::to_value(status).map_err(err_str)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。
|
/// 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_branches(
|
pub async fn list_branches(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -497,10 +497,15 @@ pub async fn list_branches(
|
|||||||
let dir = module.path.clone();
|
let dir = module.path.clone();
|
||||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||||
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
|
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
|
||||||
// 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(
|
let out = run_git_cmd(
|
||||||
std::path::Path::new(&dir),
|
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),
|
std::time::Duration::from_secs(10),
|
||||||
)
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -509,10 +514,16 @@ pub async fn list_branches(
|
|||||||
for line in out.lines() {
|
for line in out.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() { continue; }
|
if line.is_empty() { continue; }
|
||||||
// 格式: "*\0branch_name" 或 "\0branch_name"
|
// 格式: "*\0branch_name\0refs/...",三字段(HEAD 标记 / 短名 / 完整 ref)。
|
||||||
let (head, name) = line.split_once('\u{0}').unwrap_or(("", line));
|
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/<remote>/HEAD → 短名即裸 remote 名如 origin,
|
||||||
|
// 指向远端默认分支,非真实分支,列出会误导)。
|
||||||
|
let full = parts.get(2).copied().unwrap_or("");
|
||||||
|
if full.ends_with("/HEAD") { continue; }
|
||||||
let is_current = head.contains('*');
|
let is_current = head.contains('*');
|
||||||
let name = name.trim().to_string();
|
let name = short.trim().to_string();
|
||||||
if name.is_empty() { continue; }
|
if name.is_empty() { continue; }
|
||||||
if is_current { current = name.clone(); }
|
if is_current { current = name.clone(); }
|
||||||
branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
|
branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
|
||||||
@@ -655,6 +666,30 @@ const NOISE_DIRS: &[&str] = &[
|
|||||||
".vite",
|
".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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct FileTreeEntry {
|
struct FileTreeEntry {
|
||||||
@@ -668,6 +703,9 @@ struct FileTreeEntry {
|
|||||||
/// 文件夹恒为 None(只标文件)。
|
/// 文件夹恒为 None(只标文件)。
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
git_status: Option<String>,
|
git_status: Option<String>,
|
||||||
|
/// 目录是否含可见子项(过滤隐藏/噪音目录后,与展开实际展示一致)。
|
||||||
|
/// 文件恒为 false;目录在列目录时即时统计。前端未展开时行内标注空目录用。
|
||||||
|
has_children: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
|
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
|
||||||
@@ -771,12 +809,18 @@ pub async fn get_module_file_tree(
|
|||||||
} else {
|
} else {
|
||||||
git_map_clone.get(&posix_rel).cloned()
|
git_map_clone.get(&posix_rel).cloned()
|
||||||
};
|
};
|
||||||
|
let has_children = if is_dir {
|
||||||
|
dir_has_visible_children(&entry.path())
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
items.push(FileTreeEntry {
|
items.push(FileTreeEntry {
|
||||||
name,
|
name,
|
||||||
path: posix_rel,
|
path: posix_rel,
|
||||||
is_dir,
|
is_dir,
|
||||||
size,
|
size,
|
||||||
git_status,
|
git_status,
|
||||||
|
has_children,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
|
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
|
||||||
|
|||||||
+4
-1
@@ -36,6 +36,9 @@ export interface FileTreeEntry {
|
|||||||
size: number
|
size: number
|
||||||
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
|
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
|
||||||
git_status?: string
|
git_status?: string
|
||||||
|
/** 目录是否含可见子项(过滤隐藏文件/噪音目录后,与展开实际展示一致);文件恒为 false。
|
||||||
|
* 后端列目录时即时统计,前端未展开即可据此行内标注空目录。 */
|
||||||
|
has_children: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** getModuleFileTree 返回结构。 */
|
/** getModuleFileTree 返回结构。 */
|
||||||
@@ -177,7 +180,7 @@ export const moduleApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
|
/** 分页查询工程 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 }[] }> {
|
listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> {
|
||||||
return invoke('list_branches', { moduleId })
|
return invoke('list_branches', { moduleId })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,9 +80,9 @@
|
|||||||
*
|
*
|
||||||
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
|
* 节点用 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 { 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 dagre from 'dagre'
|
||||||
import { register } from '@antv/x6-vue-shape'
|
import { register } from '@antv/x6-vue-shape'
|
||||||
import '@antv/x6-vue-shape'
|
import '@antv/x6-vue-shape'
|
||||||
@@ -261,13 +261,38 @@ function renderGraph(opts?: { center?: boolean }) {
|
|||||||
if (shouldCenter) graph.centerContent()
|
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<string>()
|
||||||
|
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() {
|
function buildGraph() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
||||||
graph = new Graph({
|
graph = new Graph({
|
||||||
container: containerRef.value,
|
container: containerRef.value,
|
||||||
background: { color: '#1a1a2e' },
|
// U1:画布/grid 用主题 token 实际色值(X6 background/grid 需具体色值,非 CSS 变量),深色硬编码移除。
|
||||||
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } },
|
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 },
|
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 },
|
||||||
interacting: { nodeMovable: true },
|
interacting: { nodeMovable: true },
|
||||||
})
|
})
|
||||||
@@ -277,18 +302,31 @@ function buildGraph() {
|
|||||||
graph.use(new History({ enabled: true }))
|
graph.use(new History({ enabled: true }))
|
||||||
graph.use(new Scroller({ enabled: true, pannable: true }))
|
graph.use(new Scroller({ enabled: true, pannable: true }))
|
||||||
graph.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
|
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 }) => {
|
graph.on('node:click', ({ node }) => {
|
||||||
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
|
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
|
||||||
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
|
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
|
||||||
selectedId.value = String(node.id)
|
// F4:局部刷新选中态(仅被点节点+上次选中节点),不再整图 fromJSON 重建,保留平移/缩放态。
|
||||||
renderGraph({ center: false })
|
updateNodeSelection(String(node.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
renderGraph()
|
renderGraph()
|
||||||
bindEdgeDeleteButtons()
|
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() {
|
function fitContent() {
|
||||||
graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
|
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
|
if (!graph) return
|
||||||
try {
|
graph.toPNG((dataUri: string) => {
|
||||||
graph.toPNG((dataUri: string) => {
|
try {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = dataUri
|
a.href = dataUri
|
||||||
a.download = `dependency-graph-${props.projectId}.png`
|
a.download = `dependency-graph-${props.projectId}.png`
|
||||||
a.click()
|
a.click()
|
||||||
})
|
} catch (e) {
|
||||||
} catch (e) {
|
console.error('[DependencyGraph] 导出 PNG 失败:', e)
|
||||||
console.error('[DependencyGraph] 导出 PNG 失败:', e)
|
Message.error(t('common.unknownError'))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除依赖(二次确认 + 重绘)。 */
|
/** 删除依赖(二次确认 + 重绘)。 */
|
||||||
@@ -384,12 +428,15 @@ function hideEdgeDeleteBtn() {
|
|||||||
deleteDepTarget = null
|
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 显示删除按钮的监听。 */
|
/** 给所有 edge 绑 hover 显示删除按钮的监听。 */
|
||||||
function bindEdgeDeleteButtons() {
|
function bindEdgeDeleteButtons() {
|
||||||
if (!graph) return
|
if (!graph) return
|
||||||
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ }
|
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:mouseenter', onEdgeEnter)
|
||||||
graph.on('edge:mouseleave', onEdgeLeave)
|
graph.on('edge:mouseleave', onEdgeLeave)
|
||||||
}
|
}
|
||||||
@@ -410,6 +457,18 @@ onMounted(async () => {
|
|||||||
buildGraph()
|
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 () => {
|
watch(() => props.projectId, async () => {
|
||||||
await loadModules()
|
await loadModules()
|
||||||
renderGraph()
|
renderGraph()
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
:file-path="selectedFilePath"
|
:file-path="selectedFilePath"
|
||||||
:module-root-path="currentModule?.path ?? ''"
|
:module-root-path="currentModule?.path ?? ''"
|
||||||
:git-status="selectedFileGitStatus"
|
:git-status="selectedFileGitStatus"
|
||||||
|
:external-diff="selectedCommitDiff"
|
||||||
/>
|
/>
|
||||||
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
|
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,6 +253,8 @@ const expandedPaths = reactive(new Set<string>())
|
|||||||
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
|
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
|
||||||
const selectedFilePath = ref<string | null>(null)
|
const selectedFilePath = ref<string | null>(null)
|
||||||
const selectedFileGitStatus = ref<string | undefined>(undefined)
|
const selectedFileGitStatus = ref<string | undefined>(undefined)
|
||||||
|
/** GitChanges(变更/提交历史)点文件注入的 diff:传给右侧 FilePreview 在宽区渲染(历史提交的 diff 优先)。 */
|
||||||
|
const selectedCommitDiff = ref('')
|
||||||
|
|
||||||
const currentModule = computed(
|
const currentModule = computed(
|
||||||
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
|
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
|
||||||
@@ -302,6 +305,7 @@ function resetTreeState() {
|
|||||||
loadedChildren.clear()
|
loadedChildren.clear()
|
||||||
selectedFilePath.value = null
|
selectedFilePath.value = null
|
||||||
selectedFileGitStatus.value = undefined
|
selectedFileGitStatus.value = undefined
|
||||||
|
selectedCommitDiff.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自定义下拉选择工程。 */
|
/** 自定义下拉选择工程。 */
|
||||||
@@ -317,6 +321,8 @@ function onFileSelect(path: string) {
|
|||||||
selectedFilePath.value = path
|
selectedFilePath.value = path
|
||||||
// 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。
|
// 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。
|
||||||
selectedFileGitStatus.value = findEntryGitStatus(path)
|
selectedFileGitStatus.value = findEntryGitStatus(path)
|
||||||
|
// 树视图选中 → 回到内容预览,不残留 GitChanges 注入的 diff
|
||||||
|
selectedCommitDiff.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
|
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
|
||||||
@@ -351,6 +357,7 @@ async function refresh() {
|
|||||||
loadedChildren.clear()
|
loadedChildren.clear()
|
||||||
selectedFilePath.value = null
|
selectedFilePath.value = null
|
||||||
selectedFileGitStatus.value = undefined
|
selectedFileGitStatus.value = undefined
|
||||||
|
selectedCommitDiff.value = ''
|
||||||
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
@@ -402,10 +409,11 @@ async function switchToChanges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */
|
/** Git 变更视图中选择文件 → 在右侧预览显示 diff(变化/提交历史点文件都走此路,宽区渲染)。 */
|
||||||
function onChangeFileSelect(path: string) {
|
function onChangeFileSelect(path: string, diff?: string) {
|
||||||
selectedFilePath.value = path
|
selectedFilePath.value = path
|
||||||
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
|
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
|
||||||
|
selectedCommitDiff.value = diff ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.projectId, loadModules, { immediate: true })
|
watch(() => props.projectId, loadModules, { immediate: true })
|
||||||
@@ -761,6 +769,7 @@ async function onRemoveModule() {
|
|||||||
|
|
||||||
.explorer-tree {
|
.explorer-tree {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 8px 4px 8px 0;
|
padding: 8px 4px 8px 0;
|
||||||
|
|||||||
@@ -39,28 +39,28 @@
|
|||||||
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
|
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
|
||||||
<div v-else-if="loading" class="preview-loading">
|
<div v-else-if="loading && !isExternalDiff" class="preview-loading">
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
{{ $t('fileExplorer.loadingFile') }}
|
{{ $t('fileExplorer.loadingFile') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误 -->
|
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
|
||||||
<div v-else-if="error" class="preview-error">⚠ {{ error }}</div>
|
<div v-else-if="error && !isExternalDiff" class="preview-error">⚠ {{ error }}</div>
|
||||||
|
|
||||||
<!-- 二进制 -->
|
<!-- 二进制(外部 diff 存在时让位) -->
|
||||||
<div v-else-if="isBinary" class="preview-binary">
|
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
|
||||||
<span class="binary-icon">📄</span>
|
<span class="binary-icon">📄</span>
|
||||||
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
|
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片 -->
|
<!-- 图片(外部 diff 存在时让位) -->
|
||||||
<div v-else-if="isImage" class="preview-image">
|
<div v-else-if="isImage && !isExternalDiff" class="preview-image">
|
||||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Diff 视图(切到 diff 模式时显示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
<!-- Diff 视图(外部注入的 diff 优先展示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||||||
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
<div v-else-if="showDiff && diffViewContent" class="preview-diff">
|
||||||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||||||
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
|
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
|
||||||
@@ -98,6 +98,9 @@ const props = defineProps<{
|
|||||||
filePath: string | null
|
filePath: string | null
|
||||||
moduleRootPath: string
|
moduleRootPath: string
|
||||||
gitStatus?: string
|
gitStatus?: string
|
||||||
|
/** 外部注入的 diff(如 GitChanges 变更/提交历史点文件得到的该文件 diff,含历史提交的 diff)。
|
||||||
|
* 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求。 */
|
||||||
|
externalDiff?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { } = useMarkdown()
|
const { } = useMarkdown()
|
||||||
@@ -127,6 +130,17 @@ const showDiff = ref(false)
|
|||||||
const diffContent = ref('')
|
const diffContent = ref('')
|
||||||
const diffLoading = ref(false)
|
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 各自单调递增,await 返回后校验仍是"最新 seq"才写状态,
|
||||||
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。
|
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。
|
||||||
* 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile
|
* 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile
|
||||||
@@ -152,11 +166,11 @@ function parseHunkHeader(line: string): { oldStart: number; newStart: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffLines = computed<DiffLine[]>(() => {
|
const diffLines = computed<DiffLine[]>(() => {
|
||||||
if (!diffContent.value) return []
|
if (!diffViewContent.value) return []
|
||||||
const lines: DiffLine[] = []
|
const lines: DiffLine[] = []
|
||||||
let oldNum = 0
|
let oldNum = 0
|
||||||
let newNum = 0
|
let newNum = 0
|
||||||
for (const raw of diffContent.value.split('\n')) {
|
for (const raw of diffViewContent.value.split('\n')) {
|
||||||
if (raw.startsWith('@@')) {
|
if (raw.startsWith('@@')) {
|
||||||
const h = parseHunkHeader(raw)
|
const h = parseHunkHeader(raw)
|
||||||
oldNum = h.oldStart
|
oldNum = h.oldStart
|
||||||
@@ -180,7 +194,8 @@ const diffLines = computed<DiffLine[]>(() => {
|
|||||||
|
|
||||||
function toggleDiff() {
|
function toggleDiff() {
|
||||||
showDiff.value = !showDiff.value
|
showDiff.value = !showDiff.value
|
||||||
if (showDiff.value && !diffContent.value) {
|
// 外部 diff 已由调用方提供,无需再发 git diff;仅内部模式在首次切到 diff 时才拉取
|
||||||
|
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
|
||||||
loadDiff()
|
loadDiff()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
<!-- 错误 -->
|
<!-- 错误 -->
|
||||||
<div v-else-if="error" class="tree-error">⚠ {{ error }}</div>
|
<div v-else-if="error" class="tree-error">⚠ {{ error }}</div>
|
||||||
|
|
||||||
<!-- 空目录 -->
|
<!-- 空目录(展开后子条目为空) -->
|
||||||
<div v-else-if="entries.length === 0" class="tree-empty">{{ $t('fileExplorer.emptyDir') }}</div>
|
<div v-else-if="entries.length === 0" class="tree-empty">
|
||||||
|
<span class="tree-empty-icon">📭</span>
|
||||||
|
<span>{{ $t('fileExplorer.emptyDir') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 条目列表 -->
|
<!-- 条目列表 -->
|
||||||
<ul v-else class="tree-list">
|
<ul v-else class="tree-list">
|
||||||
@@ -39,6 +42,13 @@
|
|||||||
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
|
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
|
||||||
<span class="file-icon">📁</span>
|
<span class="file-icon">📁</span>
|
||||||
<span class="file-name">{{ entry.name }}</span>
|
<span class="file-name">{{ entry.name }}</span>
|
||||||
|
<!-- 空目录标:has_children 由后端列目录时即时统计,未展开即可见「空」,
|
||||||
|
避免用户误以为目录没加载出来(展开后才显空态) -->
|
||||||
|
<span
|
||||||
|
v-if="entry.has_children === false"
|
||||||
|
class="tree-empty-badge"
|
||||||
|
:title="$t('fileExplorer.emptyDir')"
|
||||||
|
>{{ $t('fileExplorer.emptyDirMark') }}</span>
|
||||||
<!-- B3[PD-P1-6]:展开失败 → 行内 ⚠(title 显原因),点击重试;不设整树 error,不炸其他已展开节点 -->
|
<!-- B3[PD-P1-6]:展开失败 → 行内 ⚠(title 显原因),点击重试;不设整树 error,不炸其他已展开节点 -->
|
||||||
<span
|
<span
|
||||||
v-if="toggleErrors.has(entry.path)"
|
v-if="toggleErrors.has(entry.path)"
|
||||||
@@ -356,6 +366,25 @@ watch(
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空目录小标(目录行右侧,未展开即可见;flex-shrink 防挤压) */
|
||||||
|
.tree-empty-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 文件名后稍靠右一点(不贴名、也不推到最右):固定小间隔即可 */
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(150, 150, 150, 0.14);
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开后空态图标(📭,与文字同行) */
|
||||||
|
.tree-empty-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Git 状态徽章 */
|
/* Git 状态徽章 */
|
||||||
.git-badge {
|
.git-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
|
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
|
||||||
<template v-else-if="commitDetail">
|
<template v-else-if="commitDetail">
|
||||||
<!-- 变更文件列表 -->
|
<!-- 变更文件列表(点击文件 → diff 渲染到右侧宽预览区,见 emit select-file) -->
|
||||||
<div class="gc-commit-files">
|
<div class="gc-commit-files">
|
||||||
<div
|
<div
|
||||||
v-for="f in commitDetail.files"
|
v-for="f in commitDetail.files"
|
||||||
@@ -143,25 +143,6 @@
|
|||||||
<span class="gc-file-path">{{ f.path }}</span>
|
<span class="gc-file-path">{{ f.path }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Diff 预览(可收起,占更宽空间) -->
|
|
||||||
<div v-if="commitSelectedFile" class="gc-commit-diff">
|
|
||||||
<div class="gc-commit-diff-header">
|
|
||||||
<span class="gc-commit-diff-path">{{ commitSelectedFile }}</span>
|
|
||||||
<button class="gc-commit-diff-toggle" @click="commitDiffCollapsed = !commitDiffCollapsed">
|
|
||||||
{{ commitDiffCollapsed ? $t('gitChanges.expand') : $t('gitChanges.collapse') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-show="!commitDiffCollapsed" class="gc-diff-content">
|
|
||||||
<div v-if="!commitDiff" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div>
|
|
||||||
<div v-for="(ln, idx) in commitDiffLines" :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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -172,8 +153,9 @@
|
|||||||
/**
|
/**
|
||||||
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。
|
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。
|
||||||
*
|
*
|
||||||
* 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染,
|
* 布局:变更 Tab 点文件 / 提交历史点文件 → emit select-file,把该文件 diff 注入右侧 FilePreview
|
||||||
* 避免几百行 diff 把列表挤走、或在 30% 窄栏内挤压。
|
* 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff —— 几百行 diff
|
||||||
|
* 在 30% 窄栏内渲染会被挤压/截断,故 diff 展示统一走右侧宽区。
|
||||||
*/
|
*/
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { moduleApi, type GitStatusResult } from '@/api/module'
|
import { moduleApi, type GitStatusResult } from '@/api/module'
|
||||||
@@ -188,7 +170,8 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select-file', path: string): void
|
/** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */
|
||||||
|
(e: 'select-file', path: string, diff?: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -231,8 +214,6 @@ const selectedCommit = ref<CommitItem | null>(null)
|
|||||||
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
|
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
|
||||||
const commitDetailLoading = ref(false)
|
const commitDetailLoading = ref(false)
|
||||||
const commitSelectedFile = ref('')
|
const commitSelectedFile = ref('')
|
||||||
const commitDiff = ref('')
|
|
||||||
const commitDiffCollapsed = ref(false)
|
|
||||||
|
|
||||||
const branchName = computed(() => gitStatus.value?.branch || '')
|
const branchName = computed(() => gitStatus.value?.branch || '')
|
||||||
|
|
||||||
@@ -271,33 +252,6 @@ const groupedChanges = computed<FileGroup[]>(() => {
|
|||||||
return groups.filter(g => g.files.length > 0)
|
return groups.filter(g => g.files.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
interface DiffLine {
|
|
||||||
type: 'add' | 'del' | 'ctx' | 'hdr'
|
|
||||||
prefix: string
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitDiffLines = computed<DiffLine[]>(() => 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 {
|
function statusLabel(s: string): string {
|
||||||
const x = s.trim()
|
const x = s.trim()
|
||||||
if (x === '??') return 'U'
|
if (x === '??') return 'U'
|
||||||
@@ -478,6 +432,7 @@ async function switchToHistory() {
|
|||||||
|
|
||||||
async function selectFile(path: string) {
|
async function selectFile(path: string) {
|
||||||
selectedFile.value = path
|
selectedFile.value = path
|
||||||
|
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
|
||||||
emit('select-file', path)
|
emit('select-file', path)
|
||||||
diffContent.value = ''
|
diffContent.value = ''
|
||||||
diffLoading.value = true
|
diffLoading.value = true
|
||||||
@@ -489,6 +444,10 @@ async function selectFile(path: string) {
|
|||||||
} finally {
|
} finally {
|
||||||
diffLoading.value = false
|
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
|
selectedCommit.value = c
|
||||||
commitDetail.value = null
|
commitDetail.value = null
|
||||||
commitSelectedFile.value = ''
|
commitSelectedFile.value = ''
|
||||||
commitDiff.value = ''
|
// 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位
|
||||||
commitDiffCollapsed.value = false
|
emit('select-file', '', '')
|
||||||
commitDetailLoading.value = true
|
commitDetailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
|
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
|
||||||
@@ -518,18 +477,17 @@ function closeCommitDetail() {
|
|||||||
selectedCommit.value = null
|
selectedCommit.value = null
|
||||||
commitDetail.value = null
|
commitDetail.value = null
|
||||||
commitSelectedFile.value = ''
|
commitSelectedFile.value = ''
|
||||||
commitDiff.value = ''
|
emit('select-file', '', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */
|
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */
|
||||||
function showCommitFileDiff(path: string) {
|
function showCommitFileDiff(path: string) {
|
||||||
commitSelectedFile.value = path
|
commitSelectedFile.value = path
|
||||||
commitDiffCollapsed.value = false
|
|
||||||
if (!commitDetail.value) {
|
if (!commitDetail.value) {
|
||||||
commitDiff.value = ''
|
emit('select-file', path, '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
commitDiff.value = extractFileDiff(commitDetail.value.diff, path)
|
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.moduleId, loadStatus, { immediate: true })
|
watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||||
@@ -599,23 +557,7 @@ watch(() => props.refreshKey, () => {
|
|||||||
position: sticky; top: 0; z-index: 1;
|
position: sticky; top: 0; z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Diff 预览 */
|
/* Diff 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* ====== 提交历史 ====== */
|
/* ====== 提交历史 ====== */
|
||||||
.gc-history { display: flex; flex-direction: column; min-height: 0; }
|
.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:hover { background: rgba(255,255,255,0.04); }
|
||||||
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
|
.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 {
|
.gc-detail-slide-enter-active, .gc-detail-slide-leave-active {
|
||||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
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;
|
padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
|
||||||
}
|
}
|
||||||
.gc-empty-inline { padding: 24px 0; }
|
.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 {
|
.spinner {
|
||||||
width: 16px; height: 16px;
|
width: 16px; height: 16px;
|
||||||
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
|
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
|
||||||
|
|||||||
@@ -66,21 +66,21 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #16213e;
|
background: var(--df-bg-card);
|
||||||
border: 1px solid #0f3460;
|
border: 1px solid var(--df-border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.module-node.active {
|
.module-node.active {
|
||||||
border-color: #5378e8;
|
border-color: var(--df-accent);
|
||||||
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
|
box-shadow: 0 0 0 2px var(--df-accent-soft);
|
||||||
}
|
}
|
||||||
/* 环节点红框高亮(G2):vue-shape markup 无 body 选择器,原 attrs.body 渲染无效,
|
/* 环节点红框高亮(G2):vue-shape markup 无 body 选择器,原 attrs.body 渲染无效,
|
||||||
改由依赖图把 isCycle 注入节点 data,组件根元素按类描边。 */
|
改由依赖图把 isCycle 注入节点 data,组件根元素按类描边。 */
|
||||||
.module-node--cycle {
|
.module-node--cycle {
|
||||||
border-color: #e05050;
|
border-color: var(--df-danger);
|
||||||
box-shadow: 0 0 0 2px rgba(224, 80, 80, 0.3);
|
box-shadow: 0 0 0 2px var(--df-danger-bg);
|
||||||
}
|
}
|
||||||
.module-node__header {
|
.module-node__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -93,14 +93,14 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
|||||||
.module-node__name {
|
.module-node__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e0e0e0;
|
color: var(--df-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.module-node__path {
|
.module-node__path {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #888;
|
color: var(--df-text-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -114,7 +114,7 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(83, 120, 232, 0.15);
|
background: var(--df-accent-bg);
|
||||||
color: #7c9aff;
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
// File tree
|
// File tree
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
emptyDir: 'Empty directory',
|
emptyDir: 'Empty directory',
|
||||||
|
emptyDirMark: 'empty',
|
||||||
// File preview
|
// File preview
|
||||||
selectFileHint: '← Select a file on the left to preview',
|
selectFileHint: '← Select a file on the left to preview',
|
||||||
loadingFile: 'Loading file…',
|
loadingFile: 'Loading file…',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
// 文件树
|
// 文件树
|
||||||
loading: '加载中…',
|
loading: '加载中…',
|
||||||
emptyDir: '空目录',
|
emptyDir: '空目录',
|
||||||
|
emptyDirMark: '空',
|
||||||
// 文件预览
|
// 文件预览
|
||||||
selectFileHint: '← 选择左侧文件查看预览',
|
selectFileHint: '← 选择左侧文件查看预览',
|
||||||
loadingFile: '加载文件中…',
|
loadingFile: '加载文件中…',
|
||||||
|
|||||||
@@ -1256,7 +1256,7 @@ onUnmounted(() => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; min-height: 0; flex: 1; max-height: 100%; overflow: hidden; }
|
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; height: 100%; min-height: 0; max-height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
|
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
|
||||||
.detail-tabs {
|
.detail-tabs {
|
||||||
|
|||||||
Reference in New Issue
Block a user