优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理

This commit is contained in:
lxy
2026-08-10 00:00:31 +08:00
parent 6bafdcd5a5
commit cf00345c35
19 changed files with 521 additions and 185 deletions
+18
View File
@@ -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",
+2 -1
View File
@@ -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",
+69
View File
@@ -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()
+87 -25
View File
@@ -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<dyn LlmProvider>) -> Result<AdversarialEval> {
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<AnalystAnalysis> {
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);
}
}
+67 -3
View File
@@ -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<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 {
match p {
Priority::Critical => "紧急",
+1 -1
View File
@@ -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;
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
+46 -1
View File
@@ -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");
}
}
+1
View File
@@ -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();
+50 -6
View File
@@ -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<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(
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/<remote>/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<String>,
/// 目录是否含可见子项(过滤隐藏/噪音目录后,与展开实际展示一致)。
/// 文件恒为 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,
});
}
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
+4 -1
View File
@@ -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 })
},
+71 -12
View File
@@ -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 映射无 datarender,'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() {
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) => {
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)
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()
+11 -2
View File
@@ -129,6 +129,7 @@
:file-path="selectedFilePath"
:module-root-path="currentModule?.path ?? ''"
:git-status="selectedFileGitStatus"
:external-diff="selectedCommitDiff"
/>
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
@@ -252,6 +253,8 @@ const expandedPaths = reactive(new Set<string>())
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
const selectedFilePath = ref<string | null>(null)
const selectedFileGitStatus = ref<string | undefined>(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;
+28 -13
View File
@@ -39,28 +39,28 @@
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
<!-- 加载中 -->
<div v-else-if="loading" class="preview-loading">
<!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
<div v-else-if="loading && !isExternalDiff" class="preview-loading">
<span class="spinner"></span>
{{ $t('fileExplorer.loadingFile') }}
</div>
<!-- 错误 -->
<div v-else-if="error" class="preview-error"> {{ error }}</div>
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
<div v-else-if="error && !isExternalDiff" class="preview-error"> {{ error }}</div>
<!-- 二进制 -->
<div v-else-if="isBinary" class="preview-binary">
<!-- 二进制(外部 diff 存在时让位) -->
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
<span class="binary-icon">📄</span>
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
</div>
<!-- 图片 -->
<div v-else-if="isImage" class="preview-image">
<!-- 图片(外部 diff 存在时让位) -->
<div v-else-if="isImage && !isExternalDiff" class="preview-image">
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div>
<!-- Diff 视图(切到 diff 模式时显;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
<div v-else-if="showDiff && diffContent" class="preview-diff">
<!-- Diff 视图(外部注入的 diff 优先展;置于 Markdown 之前,使 .md 文件也支持 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">
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
@@ -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<DiffLine[]>(() => {
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<DiffLine[]>(() => {
function toggleDiff() {
showDiff.value = !showDiff.value
if (showDiff.value && !diffContent.value) {
// diff , git diff; diff
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
loadDiff()
}
}
+31 -2
View File
@@ -13,8 +13,11 @@
<!-- 错误 -->
<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">
@@ -39,6 +42,13 @@
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
<span class="file-icon">📁</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,不炸其他已展开节点 -->
<span
v-if="toggleErrors.has(entry.path)"
@@ -356,6 +366,25 @@ watch(
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-badge {
flex-shrink: 0;
+18 -103
View File
@@ -130,7 +130,7 @@
</div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
<!-- 变更文件列表 -->
<!-- 变更文件列表(点击文件 diff 渲染到右侧宽预览区, emit select-file) -->
<div class="gc-commit-files">
<div
v-for="f in commitDetail.files"
@@ -143,25 +143,6 @@
<span class="gc-file-path">{{ f.path }}</span>
</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>
</div>
</transition>
@@ -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<CommitItem | null>(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<FileGroup[]>(() => {
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 {
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);
+10 -10
View File
@@ -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);
}
</style>
+1
View File
@@ -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…',
+1
View File
@@ -10,6 +10,7 @@ export default {
// 文件树
loading: '加载中…',
emptyDir: '空目录',
emptyDirMark: '空',
// 文件预览
selectFileHint: '← 选择左侧文件查看预览',
loadingFile: '加载文件中…',
+1 -1
View File
@@ -1256,7 +1256,7 @@ onUnmounted(() => {
</style>
<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 文件浏览器) ===== */
.detail-tabs {