dev-manager-tauri/src-tauri/src/commands/mentor.rs
lanrtop 0065866f6b fix: 消除 Windows 子进程黑框闪烁,修复 DB 外键迁移
- 新增 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 回填规则和批量执行完整性检查规则
2026-04-08 01:13:53 +09:00

734 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_logscategory="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)
}