- 新增 silent_cmd() 辅助函数,统一添加 CREATE_NO_WINDOW 标志,全量替换 commands/ 中的裸 std::process::Command::new 调用 - 补充 project_tools / activity_log / project_env_tools 外键从 projects 迁移到 project_workspaces 的 migrate 逻辑 - CONVENTIONS.md v1.4.2:补充 files 回填规则和批量执行完整性检查规则
734 lines
28 KiB
Rust
734 lines
28 KiB
Rust
use crate::db::pool;
|
||
use rusqlite::params;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
// ── 返回结构 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ProjectHealth {
|
||
pub path_valid: bool,
|
||
pub has_git: bool,
|
||
/// git log --oneline -1 输出
|
||
pub last_commit: Option<String>,
|
||
/// 项目记录的技术栈
|
||
pub tech_stack: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct BlueprintSummary {
|
||
pub module_count: u32,
|
||
pub done_count: u32,
|
||
pub in_progress: u32,
|
||
pub planned: u32,
|
||
pub total_tasks: u32,
|
||
pub done_tasks: u32,
|
||
/// 可派发任务数
|
||
pub dispatchable: u32,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct RuntimeLogEntry {
|
||
pub ts: String,
|
||
pub category: String,
|
||
pub level: String,
|
||
pub message: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct MentorContext {
|
||
pub project_id: String,
|
||
pub project_name: String,
|
||
pub health: ProjectHealth,
|
||
pub blueprint_summary: Option<BlueprintSummary>,
|
||
pub recent_errors: Vec<RuntimeLogEntry>,
|
||
/// 格式化好的 Markdown,可直接复制给 Claude
|
||
pub mentor_markdown: String,
|
||
}
|
||
|
||
// ── Tauri command ─────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn get_mentor_context(project_id: String) -> Result<MentorContext, String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
|
||
// 1. 从 DB 读取项目基本信息
|
||
let row: rusqlite::Result<(String, Option<String>, Option<String>, Option<String>, Option<String>)> = conn
|
||
.query_row(
|
||
"SELECT pp.name, pw.win_path, pw.wsl_path, pw.platform, pp.tech_stack
|
||
FROM project_workspaces pw
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE pw.id = ?1",
|
||
params![&project_id],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||
);
|
||
|
||
let (project_name, win_path, wsl_path, platform, tech_stack) =
|
||
row.map_err(|_| format!("项目 {} 不存在", project_id))?;
|
||
|
||
|
||
// 2. 判断是否为 WSL 项目,并提前解析 distro(只解析一次,健康检查和蓝图共用)
|
||
// - platform == "wsl":明确标记
|
||
// - 旧数据兼容:wsl_path 有值且 win_path 为空(迁移时 COALESCE 可能错填 'windows')
|
||
let is_wsl = platform.as_deref() == Some("wsl")
|
||
|| (wsl_path.is_some()
|
||
&& (win_path.is_none() || win_path.as_deref() == Some("")));
|
||
|
||
// 2b. 若 wsl_path 存的是 UNC 路径(\\wsl.localhost\Distro\...),
|
||
// 自动提取 distro 和对应的 Linux 路径;否则视为原始 Linux 路径
|
||
let (wsl_distro, wsl_linux_path): (Option<String>, Option<String>) = if is_wsl {
|
||
match wsl_path.as_deref() {
|
||
Some(p) if p.starts_with("\\\\wsl.localhost\\") || p.starts_with("\\\\wsl$\\") => {
|
||
// UNC 格式:\\wsl.localhost\Ubuntu-22.04\home\user\project
|
||
let (distro, linux) = unc_to_distro_and_linux(p);
|
||
(Some(distro), Some(linux))
|
||
}
|
||
Some(p) if !p.is_empty() => {
|
||
// Linux 格式:/home/user/project
|
||
let configured = read_wsl_distro(&conn);
|
||
let distro = resolve_wsl_distro(&configured, p);
|
||
(Some(distro), Some(p.to_string()))
|
||
}
|
||
_ => (None, None),
|
||
}
|
||
} else {
|
||
(None, None)
|
||
};
|
||
|
||
// 3. 本地健康检查
|
||
let (path_valid, has_git, last_commit) = match (is_wsl, &wsl_linux_path, &wsl_distro) {
|
||
(true, Some(linux_path), Some(distro)) => {
|
||
let pv = wsl_test_dir(distro, linux_path);
|
||
let hg = pv && wsl_test_dir(distro, &format!("{}/.git", linux_path));
|
||
let lc = if hg { wsl_git_log(distro, linux_path) } else { None };
|
||
(pv, hg, lc)
|
||
}
|
||
(true, _, _) => (false, false, None),
|
||
(false, _, _) => match win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new) {
|
||
Some(root) => {
|
||
let pv = root.exists();
|
||
let hg = root.join(".git").exists();
|
||
let lc = if hg { win_git_log(root) } else { None };
|
||
(pv, hg, lc)
|
||
}
|
||
None => (false, false, None),
|
||
},
|
||
};
|
||
|
||
let health = ProjectHealth {
|
||
path_valid,
|
||
has_git,
|
||
last_commit,
|
||
tech_stack,
|
||
};
|
||
|
||
// 4. 蓝图摘要(复用已解析的 wsl_distro / wsl_linux_path)
|
||
let blueprint_summary = match (is_wsl, &wsl_linux_path, &wsl_distro) {
|
||
(true, Some(linux_path), Some(distro)) => {
|
||
read_blueprint_summary_wsl(linux_path, distro)
|
||
}
|
||
(true, _, _) => None,
|
||
(false, _, _) => {
|
||
win_path.as_deref().filter(|p| !p.is_empty()).map(Path::new).and_then(read_blueprint_summary_win)
|
||
}
|
||
};
|
||
|
||
// 5. 近期错误日志
|
||
let recent_errors = read_recent_errors(&conn, &project_id);
|
||
|
||
// 6. 生成 mentor_markdown
|
||
let mentor_markdown = build_mentor_markdown(
|
||
&project_id,
|
||
&project_name,
|
||
&health,
|
||
&blueprint_summary,
|
||
&recent_errors,
|
||
);
|
||
|
||
Ok(MentorContext {
|
||
project_id,
|
||
project_name,
|
||
health,
|
||
blueprint_summary,
|
||
recent_errors,
|
||
mentor_markdown,
|
||
})
|
||
}
|
||
|
||
// ── WSL 辅助 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// 从 settings 表读取 wsl_distro,默认 "Ubuntu"
|
||
fn read_wsl_distro(conn: &rusqlite::Connection) -> String {
|
||
conn.query_row(
|
||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap_or_else(|_| "Ubuntu".to_string())
|
||
}
|
||
|
||
/// 用 `wsl -d <distro> -- test -d <path>` 检查 Linux 路径是否存在(目录)
|
||
/// 比 Path::exists() 对 \\wsl.localhost\... UNC 路径更可靠
|
||
/// stderr 重定向到 null,避免 WSL 网络警告打印到控制台
|
||
fn wsl_test_dir(distro: &str, linux_path: &str) -> bool {
|
||
crate::silent_cmd("wsl")
|
||
.args(["-d", distro, "--", "test", "-d", linux_path])
|
||
.stderr(std::process::Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 解析 WSL 项目实际使用的发行版名称:
|
||
/// 1. 先试配置的 distro
|
||
/// 2. 失败则枚举 `wsl -l -q` 找第一个能访问该 linux_path 的 distro
|
||
fn resolve_wsl_distro(configured: &str, linux_path: &str) -> String {
|
||
if wsl_test_dir(configured, linux_path) {
|
||
return configured.to_string();
|
||
}
|
||
// 枚举所有已安装 distro
|
||
let distros = list_wsl_distros();
|
||
distros.into_iter()
|
||
.find(|d| wsl_test_dir(d, linux_path))
|
||
.unwrap_or_else(|| configured.to_string())
|
||
}
|
||
|
||
/// 列出系统所有已安装 WSL 发行版(解析 UTF-16 LE 输出)
|
||
fn list_wsl_distros() -> Vec<String> {
|
||
let output = match crate::silent_cmd("wsl")
|
||
.args(["-l", "-q"])
|
||
.stderr(std::process::Stdio::null())
|
||
.output()
|
||
{
|
||
Ok(o) => o,
|
||
Err(_) => return vec![],
|
||
};
|
||
let bytes = &output.stdout;
|
||
let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||
let words: Vec<u16> = bytes[2..]
|
||
.chunks(2)
|
||
.filter(|c| c.len() == 2)
|
||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||
.collect();
|
||
String::from_utf16_lossy(&words).to_string()
|
||
} else if bytes.windows(2).any(|w| w[1] == 0) {
|
||
let words: Vec<u16> = bytes
|
||
.chunks(2)
|
||
.filter(|c| c.len() == 2)
|
||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||
.collect();
|
||
String::from_utf16_lossy(&words).to_string()
|
||
} else {
|
||
String::from_utf8_lossy(bytes).to_string()
|
||
};
|
||
text.lines()
|
||
.map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\0').to_string())
|
||
.filter(|l| !l.is_empty())
|
||
.collect()
|
||
}
|
||
|
||
/// 在 WSL 内运行 git log,返回最近一条提交的单行摘要
|
||
fn wsl_git_log(distro: &str, linux_path: &str) -> Option<String> {
|
||
let output = crate::silent_cmd("wsl")
|
||
.args(["-d", distro, "--", "git", "-C", linux_path, "log", "--oneline", "-1"])
|
||
.stderr(std::process::Stdio::null())
|
||
.output()
|
||
.ok()?;
|
||
if output.status.success() {
|
||
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
if s.is_empty() { None } else { Some(s) }
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// 将 Linux 路径转为 \\wsl.localhost\<distro>\... UNC 路径
|
||
fn linux_to_unc(linux_path: &str, distro: &str) -> PathBuf {
|
||
let inner = linux_path.trim_start_matches('/').replace('/', "\\");
|
||
PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner))
|
||
}
|
||
|
||
/// UNC 路径 → (distro, linux_path)
|
||
/// \\wsl.localhost\Ubuntu-22.04\home\user\proj → ("Ubuntu-22.04", "/home/user/proj")
|
||
fn unc_to_distro_and_linux(unc: &str) -> (String, String) {
|
||
let prefix = if unc.starts_with("\\\\wsl.localhost\\") {
|
||
"\\\\wsl.localhost\\"
|
||
} else {
|
||
"\\\\wsl$\\"
|
||
};
|
||
let rest = &unc[prefix.len()..];
|
||
let mut parts = rest.splitn(2, '\\');
|
||
let distro = parts.next().unwrap_or("Ubuntu").to_string();
|
||
let inner = parts.next().unwrap_or("");
|
||
let linux = format!("/{}", inner.replace('\\', "/"));
|
||
(distro, linux)
|
||
}
|
||
|
||
// ── Windows 辅助 ──────────────────────────────────────────────────────────────
|
||
|
||
fn win_git_log(root: &Path) -> Option<String> {
|
||
crate::silent_cmd("git")
|
||
.args(["log", "--oneline", "-1"])
|
||
.current_dir(root)
|
||
.output()
|
||
.ok()
|
||
.filter(|o| o.status.success())
|
||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
}
|
||
|
||
// ── 蓝图摘要 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// WSL 项目:用 wsl 命令判断 manifest 存在性,再通过 UNC 路径读取内容
|
||
fn read_blueprint_summary_wsl(linux_path: &str, distro: &str) -> Option<BlueprintSummary> {
|
||
let manifest_linux = format!("{}/.blueprint/manifest.yaml", linux_path);
|
||
// 用 wsl test -f 判断文件存在(不依赖 Path::exists())
|
||
let exists = crate::silent_cmd("wsl")
|
||
.args(["-d", distro, "--", "test", "-f", &manifest_linux])
|
||
.stderr(std::process::Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false);
|
||
if !exists {
|
||
return None;
|
||
}
|
||
// 文件存在,通过 UNC 路径读取(CreateFileW 可正常访问 \\wsl.localhost\...)
|
||
let unc_root = linux_to_unc(linux_path, distro);
|
||
parse_blueprint_summary(&unc_root)
|
||
}
|
||
|
||
/// Windows 项目:走常规 Path::exists()
|
||
fn read_blueprint_summary_win(root: &Path) -> Option<BlueprintSummary> {
|
||
if !root.join(".blueprint").join("manifest.yaml").exists() {
|
||
return None;
|
||
}
|
||
parse_blueprint_summary(root)
|
||
}
|
||
|
||
fn parse_blueprint_summary(root: &Path) -> Option<BlueprintSummary> {
|
||
let result = crate::commands::blueprint::get_blueprint(root.to_string_lossy().to_string());
|
||
match result {
|
||
Ok(Some(data)) => {
|
||
let s = &data.stats;
|
||
Some(BlueprintSummary {
|
||
module_count: s.total_modules,
|
||
done_count: s.done,
|
||
in_progress: s.in_progress,
|
||
planned: s.planned,
|
||
total_tasks: s.total_tasks,
|
||
done_tasks: s.tasks_done,
|
||
dispatchable: s.dispatchable,
|
||
})
|
||
}
|
||
_ => {
|
||
// manifest.yaml 存在但解析失败时,返回有蓝图但无法读取的状态
|
||
Some(BlueprintSummary {
|
||
module_count: 0,
|
||
done_count: 0,
|
||
in_progress: 0,
|
||
planned: 0,
|
||
total_tasks: 0,
|
||
done_tasks: 0,
|
||
dispatchable: 0,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 错误日志 ──────────────────────────────────────────────────────────────────
|
||
|
||
fn read_recent_errors(
|
||
conn: &rusqlite::Connection,
|
||
project_id: &str,
|
||
) -> Vec<RuntimeLogEntry> {
|
||
let sql = "SELECT ts, category, level, message FROM runtime_logs
|
||
WHERE level IN ('error', 'warn')
|
||
AND (json_extract(context, '$.project_id') = ?1
|
||
OR context IS NULL)
|
||
ORDER BY id DESC LIMIT 20";
|
||
|
||
let result = conn.prepare(sql).and_then(|mut stmt| {
|
||
stmt.query_map(params![project_id], |row| {
|
||
Ok(RuntimeLogEntry {
|
||
ts: row.get(0)?,
|
||
category: row.get(1)?,
|
||
level: row.get(2)?,
|
||
message: row.get(3)?,
|
||
})
|
||
})
|
||
.map(|iter| iter.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||
});
|
||
|
||
result.unwrap_or_default()
|
||
}
|
||
|
||
// ── Markdown 生成 ─────────────────────────────────────────────────────────────
|
||
|
||
fn build_mentor_markdown(
|
||
project_id: &str,
|
||
project_name: &str,
|
||
health: &ProjectHealth,
|
||
blueprint: &Option<BlueprintSummary>,
|
||
errors: &[RuntimeLogEntry],
|
||
) -> String {
|
||
let mut md = String::new();
|
||
|
||
md.push_str(&format!("# 项目导师报告:{}\n\n", project_name));
|
||
md.push_str(&format!("项目 ID:`{}`\n\n", project_id));
|
||
|
||
// ── 本地健康 ──────────────────────────────────────────────────────────────
|
||
md.push_str("## 本地健康\n\n");
|
||
let path_icon = if health.path_valid { "✅" } else { "❌" };
|
||
let git_icon = if health.has_git { "✅" } else { "⚠️" };
|
||
md.push_str(&format!("- 本地路径:{} {}\n", path_icon, if health.path_valid { "有效" } else { "路径不存在" }));
|
||
md.push_str(&format!("- Git 仓库:{} {}\n", git_icon, if health.has_git { "已初始化" } else { "未找到 .git 目录" }));
|
||
if let Some(ref stack) = health.tech_stack {
|
||
md.push_str(&format!("- 技术栈:{}\n", stack));
|
||
}
|
||
if let Some(ref commit) = health.last_commit {
|
||
md.push_str(&format!("- 最近提交:`{}`\n", commit));
|
||
}
|
||
|
||
// ── 蓝图状态 ──────────────────────────────────────────────────────────────
|
||
md.push_str("\n## 蓝图状态\n\n");
|
||
if let Some(ref bp) = blueprint {
|
||
if bp.module_count == 0 {
|
||
md.push_str("蓝图存在,但暂无模块数据。\n");
|
||
} else {
|
||
let progress = (bp.done_count as f64 / bp.module_count as f64 * 100.0).round() as u32;
|
||
md.push_str("| 维度 | 数值 |\n|------|------|\n");
|
||
md.push_str(&format!("| 模块总数 | {} |\n", bp.module_count));
|
||
md.push_str(&format!("| 已完成模块 | {} ({progress}%) |\n", bp.done_count));
|
||
md.push_str(&format!("| 进行中 | {} |\n", bp.in_progress));
|
||
md.push_str(&format!("| 已规划 | {} |\n", bp.planned));
|
||
md.push_str(&format!("| 任务总数 | {} |\n", bp.total_tasks));
|
||
md.push_str(&format!("| 已完成任务 | {} |\n", bp.done_tasks));
|
||
md.push_str(&format!("| 可派发任务 | {} |\n", bp.dispatchable));
|
||
}
|
||
} else {
|
||
md.push_str("该项目尚未接入蓝图(无 .blueprint/manifest.yaml)。\n");
|
||
}
|
||
|
||
// ── 近期错误日志 ──────────────────────────────────────────────────────────
|
||
md.push_str("\n## 近期错误日志(最多 20 条)\n\n");
|
||
if errors.is_empty() {
|
||
md.push_str("无错误记录 ✅\n");
|
||
} else {
|
||
for e in errors {
|
||
md.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", e.ts, e.level, e.category, e.message));
|
||
}
|
||
}
|
||
|
||
md.push_str("\n---\n\n请根据以上信息,对该项目的当前状态、质量和下一步方向给出你的判断与建议。\n");
|
||
|
||
md
|
||
}
|
||
|
||
// ── 项目笔记 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize, Deserialize)]
|
||
pub struct ProjectNote {
|
||
pub id: i64,
|
||
pub project_id: String,
|
||
pub source: String,
|
||
pub content: String,
|
||
pub created_at: String,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_project_notes(project_id: String) -> Result<Vec<ProjectNote>, String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT id, project_id, source, content, created_at
|
||
FROM project_notes
|
||
WHERE project_id = ?1
|
||
ORDER BY id DESC LIMIT 10",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
let notes = stmt
|
||
.query_map(params![project_id], |row| {
|
||
Ok(ProjectNote {
|
||
id: row.get(0)?,
|
||
project_id: row.get(1)?,
|
||
source: row.get(2)?,
|
||
content: row.get(3)?,
|
||
created_at: row.get(4)?,
|
||
})
|
||
})
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
Ok(notes)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn add_project_note(
|
||
project_id: String,
|
||
content: String,
|
||
source: String,
|
||
) -> Result<(), String> {
|
||
if content.trim().is_empty() {
|
||
return Err("笔记内容不能为空".to_string());
|
||
}
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, ?2, ?3)",
|
||
params![project_id, source, content],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
// ── 启动健康扫描 ──────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct StartupAlert {
|
||
pub project_name: String,
|
||
pub alert_type: String,
|
||
pub detail: String,
|
||
}
|
||
|
||
/// 应用启动时调用,扫描所有项目:
|
||
/// - stale_git:超过 14 天无 git 提交
|
||
/// - blocked_tasks:蓝图中有 🔴 blocked 任务
|
||
/// 结果写入 runtime_logs(category="startup_alert"),同时返回告警列表供前端展示
|
||
pub fn run_startup_health_scan() {
|
||
let conn = match pool().get() {
|
||
Ok(c) => c,
|
||
Err(_) => return,
|
||
};
|
||
|
||
// 清除上次启动的 startup_alert 记录,避免堆积
|
||
let _ = conn.execute(
|
||
"DELETE FROM runtime_logs WHERE category = 'startup_alert'",
|
||
[],
|
||
);
|
||
|
||
// 查询所有 workspace 的基本信息
|
||
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>)> = {
|
||
let mut stmt = match conn.prepare(
|
||
"SELECT pw.id, pp.name, pw.win_path, pw.wsl_path, pw.platform
|
||
FROM project_workspaces pw
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id",
|
||
) {
|
||
Ok(s) => s,
|
||
Err(_) => return,
|
||
};
|
||
stmt.query_map([], |row| {
|
||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||
})
|
||
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
||
.unwrap_or_default()
|
||
};
|
||
|
||
for (project_id, project_name, win_path, wsl_path, platform) in rows {
|
||
// 1. 检查 git 活跃度(超 14 天无提交)
|
||
// 直接获取距今天数,None 表示路径不存在或无 git(跳过,不是"停滞")
|
||
if let Some(days) = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn) {
|
||
if days > 14 {
|
||
// \x1f (ASCII Unit Separator) 作分隔符,避免项目名含 | 导致解析错位
|
||
crate::db::log_event(
|
||
"startup_alert",
|
||
"warn",
|
||
&format!("{}\x1fstale_git\x1f{} 天未提交", project_name, days),
|
||
Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 2. 检查蓝图 blocked 任务
|
||
let blueprint_path_opt = if platform.as_deref() == Some("wsl") {
|
||
wsl_path.as_deref().and_then(|lp| {
|
||
let distro = conn
|
||
.query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0))
|
||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||
let manifest_linux = format!("{}/.blueprint/manifest.yaml", lp);
|
||
let exists = crate::silent_cmd("wsl")
|
||
.args(["-d", &distro, "--", "test", "-f", &manifest_linux])
|
||
.stderr(std::process::Stdio::null())
|
||
.status()
|
||
.map(|s| s.success())
|
||
.unwrap_or(false);
|
||
if exists {
|
||
let inner = lp.trim_start_matches('/').replace('/', "\\");
|
||
Some(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}\\.blueprint", distro, inner)))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
} else {
|
||
win_path.as_deref().map(|p| std::path::Path::new(p).join(".blueprint"))
|
||
};
|
||
|
||
if let Some(bp_dir) = blueprint_path_opt {
|
||
let blocked_count = count_blocked_tasks(&bp_dir);
|
||
if blocked_count > 0 {
|
||
crate::db::log_event(
|
||
"startup_alert",
|
||
"warn",
|
||
&format!("{}\x1fblocked_tasks\x1f{} 个任务阻塞", project_name, blocked_count),
|
||
Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 获取项目最后一次 git 提交距今天数
|
||
fn get_days_since_last_commit(
|
||
platform: Option<&str>,
|
||
win_path: Option<&str>,
|
||
wsl_path: Option<&str>,
|
||
conn: &rusqlite::Connection,
|
||
) -> Option<u64> {
|
||
let output = if platform == Some("wsl") {
|
||
let lp = wsl_path?;
|
||
let distro = conn
|
||
.query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0))
|
||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||
crate::silent_cmd("wsl")
|
||
.args(["-d", &distro, "--", "git", "-C", lp, "log", "-1", "--format=%ct"])
|
||
.stderr(std::process::Stdio::null())
|
||
.output()
|
||
.ok()?
|
||
} else {
|
||
let root = win_path?;
|
||
crate::silent_cmd("git")
|
||
.args(["log", "-1", "--format=%ct"])
|
||
.current_dir(root)
|
||
.output()
|
||
.ok()?
|
||
};
|
||
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
let ts_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
let commit_ts: u64 = ts_str.parse().ok()?;
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.ok()?
|
||
.as_secs();
|
||
Some((now.saturating_sub(commit_ts)) / 86400)
|
||
}
|
||
|
||
/// 统计 .blueprint/modules/ 下所有 .md 文件中 🔴 前缀任务卡的数量
|
||
fn count_blocked_tasks(blueprint_dir: &std::path::Path) -> usize {
|
||
let modules_dir = blueprint_dir.join("modules");
|
||
let entries = match std::fs::read_dir(&modules_dir) {
|
||
Ok(e) => e,
|
||
Err(_) => return 0,
|
||
};
|
||
let mut count = 0;
|
||
for entry in entries.filter_map(|e| e.ok()) {
|
||
if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
|
||
continue;
|
||
}
|
||
if let Ok(content) = std::fs::read_to_string(entry.path()) {
|
||
count += content.lines().filter(|l| l.starts_with("### 🔴")).count();
|
||
}
|
||
}
|
||
count
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_startup_alerts() -> Result<Vec<StartupAlert>, String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT message FROM runtime_logs
|
||
WHERE category = 'startup_alert' AND level = 'warn'
|
||
ORDER BY id DESC LIMIT 50",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let alerts = stmt
|
||
.query_map([], |row| row.get::<_, String>(0))
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.filter_map(|msg| {
|
||
// 消息格式:"{project_name}\x1f{alert_type}\x1f{detail}"
|
||
// 使用 ASCII Unit Separator 避免项目名含 | 导致解析错位
|
||
let parts: Vec<&str> = msg.splitn(3, '\x1f').collect();
|
||
if parts.len() == 3 {
|
||
Some(StartupAlert {
|
||
project_name: parts[0].to_string(),
|
||
alert_type: parts[1].to_string(),
|
||
detail: parts[2].to_string(),
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
Ok(alerts)
|
||
}
|
||
|
||
// ── 全组巡检 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub fn get_group_mentor_report(group_id: String) -> Result<String, String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
|
||
// 查询该产品组所有成员项目
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT pw.id, pp.name
|
||
FROM project_group_map pgm
|
||
JOIN project_workspaces pw ON pw.id = pgm.project_id
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE pgm.group_id = ?1
|
||
ORDER BY pp.name",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let members: Vec<(String, String)> = stmt
|
||
.query_map(params![group_id], |row| Ok((row.get(0)?, row.get(1)?)))
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
|
||
if members.is_empty() {
|
||
return Err("该产品组暂无成员项目".to_string());
|
||
}
|
||
|
||
let mut report = format!(
|
||
"# 全组巡检报告\n\n产品组 ID:`{}`\n共 {} 个项目\n\n---\n\n",
|
||
group_id,
|
||
members.len()
|
||
);
|
||
|
||
for (project_id, project_name) in &members {
|
||
report.push_str(&format!("## {}\n\n", project_name));
|
||
match get_mentor_context(project_id.clone()) {
|
||
Ok(ctx) => {
|
||
// 去掉一级标题,同时把 ## 章节降级为 ###,避免与项目名 ## 同级混淆
|
||
let body = ctx
|
||
.mentor_markdown
|
||
.lines()
|
||
.skip_while(|l| l.starts_with("# "))
|
||
.map(|l| {
|
||
if l.starts_with("## ") {
|
||
format!("#{}", l)
|
||
} else {
|
||
l.to_string()
|
||
}
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
report.push_str(body.trim_start());
|
||
}
|
||
Err(e) => {
|
||
report.push_str(&format!("_获取导师上下文失败:{}_\n", e));
|
||
}
|
||
}
|
||
report.push_str("\n\n---\n\n");
|
||
}
|
||
|
||
report.push_str("请根据以上所有项目的信息,对这个产品组的整体状态给出综合分析和下一步建议。\n");
|
||
|
||
Ok(report)
|
||
}
|