feat: complete project mentor feature with WSL path fix
- 新增 project_notes 数据层(T5):DB 迁移 + get/add 命令 - MCP 新增 append_project_note 工具,含 group 归属校验(T6) - MentorPanel 底部展示 AI 笔记区域(T7) - 后端全组巡检命令 get_group_mentor_report(T8) - ManagePage 产品组卡片新增「巡检全组」按钮(T9) - 启动健康扫描 + App.tsx 顶部告警 Banner(T10) - 修复 WSL 项目路径检测:wsl_path 为 UNC 格式时 用 unc_to_distro_and_linux() 转换后再调用 wsl 子命令
This commit is contained in:
parent
eb346ae1fd
commit
af88408669
@ -1,6 +1,6 @@
|
|||||||
use crate::db::pool;
|
use crate::db::pool;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
// ── 返回结构 ──────────────────────────────────────────────────────────────────
|
// ── 返回结构 ──────────────────────────────────────────────────────────────────
|
||||||
@ -423,3 +423,311 @@ fn build_mentor_markdown(
|
|||||||
|
|
||||||
md
|
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 = std::process::Command::new("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());
|
||||||
|
std::process::Command::new("wsl")
|
||||||
|
.args(["-d", &distro, "--", "git", "-C", lp, "log", "-1", "--format=%ct"])
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.ok()?
|
||||||
|
} else {
|
||||||
|
let root = win_path?;
|
||||||
|
std::process::Command::new("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)
|
||||||
|
}
|
||||||
|
|||||||
@ -401,6 +401,17 @@ fn migrate(conn: &rusqlite::Connection) {
|
|||||||
context TEXT
|
context TEXT
|
||||||
);
|
);
|
||||||
").expect("create runtime_logs table");
|
").expect("create runtime_logs table");
|
||||||
|
|
||||||
|
// AI 笔记(Claude 通过 MCP 或用户手动写入的分析结论)
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS project_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'mcp',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
").expect("create project_notes table");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -5,7 +5,7 @@ mod mcp_server;
|
|||||||
mod models;
|
mod models;
|
||||||
use git2;
|
use git2;
|
||||||
|
|
||||||
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
|
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@ -45,6 +45,11 @@ pub fn run() {
|
|||||||
let mcp_port = mcp_server::read_port();
|
let mcp_port = mcp_server::read_port();
|
||||||
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
|
tauri::async_runtime::spawn(mcp_server::start(mcp_port));
|
||||||
|
|
||||||
|
// ── 启动健康扫描(异步,不阻塞窗口渲染)────────────────
|
||||||
|
tauri::async_runtime::spawn(async {
|
||||||
|
tokio::task::spawn_blocking(run_startup_health_scan).await.ok();
|
||||||
|
});
|
||||||
|
|
||||||
// ── 系统托盘 ──────────────────────────────────────────────
|
// ── 系统托盘 ──────────────────────────────────────────────
|
||||||
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||||
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
|
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
|
||||||
@ -232,6 +237,10 @@ pub fn run() {
|
|||||||
save_canvas_state,
|
save_canvas_state,
|
||||||
// mentor
|
// mentor
|
||||||
get_mentor_context,
|
get_mentor_context,
|
||||||
|
get_project_notes,
|
||||||
|
add_project_note,
|
||||||
|
get_group_mentor_report,
|
||||||
|
get_startup_alerts,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -162,6 +162,24 @@ fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["project_id"]
|
"required": ["project_id"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "append_project_note",
|
||||||
|
"description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "项目 workspace ID(来自 list_group_projects 的返回结果)"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "笔记内容,支持 Markdown。建议包含:核心观点、发现的问题、下一步建议。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["project_id", "content"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@ -200,6 +218,11 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
|
|||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
get_project_mentor_context(&gid, pid)
|
get_project_mentor_context(&gid, pid)
|
||||||
}
|
}
|
||||||
|
"append_project_note" => {
|
||||||
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
append_project_note(&gid, pid, content)
|
||||||
|
}
|
||||||
_ => Err(format!("未知工具: {}", tool_name)),
|
_ => Err(format!("未知工具: {}", tool_name)),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@ -454,3 +477,28 @@ fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String
|
|||||||
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
||||||
Ok(ctx.mentor_markdown)
|
Ok(ctx.mentor_markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result<String, String> {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err("笔记内容不能为空".to_string());
|
||||||
|
}
|
||||||
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
// 校验 project_id 属于该 group
|
||||||
|
let exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
|
||||||
|
[group_id, project_id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|n| n > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !exists {
|
||||||
|
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
|
||||||
|
rusqlite::params![project_id, content],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok("笔记已保存 ✅".to_string())
|
||||||
|
}
|
||||||
|
|||||||
48
src/App.tsx
48
src/App.tsx
@ -10,7 +10,7 @@ import { HealthPage } from "./components/health/HealthPage";
|
|||||||
import { Sidebar, type Page } from "./components/layout/Sidebar";
|
import { Sidebar, type Page } from "./components/layout/Sidebar";
|
||||||
import { Toast } from "./components/ui/Toast";
|
import { Toast } from "./components/ui/Toast";
|
||||||
import LoginPage from "./components/auth/LoginPage";
|
import LoginPage from "./components/auth/LoginPage";
|
||||||
import { githubGetAccount, githubLogout, type GitAccount } from "./lib/commands";
|
import { githubGetAccount, githubLogout, getStartupAlerts, type GitAccount, type StartupAlert } from "./lib/commands";
|
||||||
import { useSpacesSync } from "./hooks/useSpacesSync";
|
import { useSpacesSync } from "./hooks/useSpacesSync";
|
||||||
import { useUIStore } from "./store/ui";
|
import { useUIStore } from "./store/ui";
|
||||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||||
@ -18,6 +18,8 @@ import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<Page>("overview");
|
const [page, setPage] = useState<Page>("overview");
|
||||||
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
|
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
|
||||||
|
const [alerts, setAlerts] = useState<StartupAlert[]>([]);
|
||||||
|
const [alertsDismissed, setAlertsDismissed] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const showToast = useUIStore((s) => s.showToast);
|
const showToast = useUIStore((s) => s.showToast);
|
||||||
|
|
||||||
@ -36,6 +38,17 @@ export default function App() {
|
|||||||
.catch(() => {}); // 静默失败,不打扰用户
|
.catch(() => {}); // 静默失败,不打扰用户
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 启动健康扫描结果(延迟 2s 等后端扫描完成)
|
||||||
|
useEffect(() => {
|
||||||
|
if (account == null) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
getStartupAlerts()
|
||||||
|
.then((list) => { if (list.length > 0) setAlerts(list); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await githubLogout();
|
await githubLogout();
|
||||||
qc.clear();
|
qc.clear();
|
||||||
@ -64,13 +77,32 @@ export default function App() {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-1 overflow-auto bg-gray-50">
|
<main className="flex-1 overflow-auto bg-gray-50 flex flex-col min-h-0">
|
||||||
{page === "overview" && <OverviewPage />}
|
{alerts.length > 0 && !alertsDismissed && (
|
||||||
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />}
|
<div className="shrink-0 bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-start gap-3">
|
||||||
{page === "devtools" && <DevToolsPage />}
|
<span className="text-amber-500 shrink-0 mt-0.5">⚠️</span>
|
||||||
{page === "proxy" && <ProxyPage />}
|
<div className="flex-1 min-w-0">
|
||||||
{page === "settings" && <SettingsPage />}
|
<span className="text-xs font-semibold text-amber-800">项目需要关注:</span>
|
||||||
{page === "health" && <HealthPage />}
|
<span className="text-xs text-amber-700 ml-1">
|
||||||
|
{alerts.slice(0, 3).map((a) => `${a.project_name}(${a.detail})`).join("、")}
|
||||||
|
{alerts.length > 3 && `,等 ${alerts.length - 3} 个项目`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAlertsDismissed(true)}
|
||||||
|
className="shrink-0 text-amber-400 hover:text-amber-600 text-sm leading-none"
|
||||||
|
aria-label="关闭"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
|
{page === "overview" && <OverviewPage />}
|
||||||
|
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />}
|
||||||
|
{page === "devtools" && <DevToolsPage />}
|
||||||
|
{page === "proxy" && <ProxyPage />}
|
||||||
|
{page === "settings" && <SettingsPage />}
|
||||||
|
{page === "health" && <HealthPage />}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ProjectModal />
|
<ProjectModal />
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
deleteProject, getGroups, getGroupMaps, getProjects, getSpaces,
|
deleteProject, getGroups, getGroupMaps, getProjects, getSpaces,
|
||||||
deleteGroup, addGroupMember, removeGroupMember,
|
deleteGroup, addGroupMember, removeGroupMember,
|
||||||
createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate,
|
createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate,
|
||||||
|
getGroupMentorReport,
|
||||||
type ProductGroup, type Project, type ProjectTemplate,
|
type ProductGroup, type Project, type ProjectTemplate,
|
||||||
} from "../../lib/commands";
|
} from "../../lib/commands";
|
||||||
import { useUIStore } from "../../store/ui";
|
import { useUIStore } from "../../store/ui";
|
||||||
@ -211,6 +212,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
|
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
|
||||||
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
|
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
|
||||||
const [delGroup, setDelGroup] = useState<string | null>(null);
|
const [delGroup, setDelGroup] = useState<string | null>(null);
|
||||||
|
const [inspecting, setInspecting] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleInspect = async (groupId: string) => {
|
||||||
|
setInspecting((prev) => new Set(prev).add(groupId));
|
||||||
|
try {
|
||||||
|
const report = await getGroupMentorReport(groupId);
|
||||||
|
await navigator.clipboard.writeText(report);
|
||||||
|
showToast("已复制,可粘贴给 Claude 进行全组分析");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`❌ 巡检失败: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setInspecting((prev) => { const s = new Set(prev); s.delete(groupId); return s; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { data: groups = [] } = useQuery({
|
const { data: groups = [] } = useQuery({
|
||||||
queryKey: ["groups", spaceId],
|
queryKey: ["groups", spaceId],
|
||||||
@ -281,6 +296,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInspect(g.id)}
|
||||||
|
disabled={inspecting.has(g.id)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-xs text-indigo-600 hover:bg-indigo-50 transition-colors disabled:opacity-50 flex items-center gap-1"
|
||||||
|
title="聚合全组导师报告并复制到剪贴板"
|
||||||
|
>
|
||||||
|
{inspecting.has(g.id) ? (
|
||||||
|
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||||
|
</svg>
|
||||||
|
) : "🔍"}
|
||||||
|
巡检全组
|
||||||
|
</button>
|
||||||
<button onClick={() => setMemberTarget(g)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors">
|
<button onClick={() => setMemberTarget(g)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors">
|
||||||
+ 成员
|
+ 成员
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getMentorContext, type MentorContext } from "../../lib/commands";
|
import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -18,6 +18,13 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: notes = [] } = useQuery<ProjectNote[]>({
|
||||||
|
queryKey: ["projectNotes", projectId],
|
||||||
|
queryFn: () => getProjectNotes(projectId),
|
||||||
|
staleTime: 30000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {});
|
navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {});
|
||||||
@ -143,6 +150,28 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* AI 笔记 */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">AI 笔记</h3>
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">暂无笔记。Claude 通过 MCP 分析项目后会在此留下洞察。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<li key={note.id} className="flex flex-col gap-1 rounded-lg bg-indigo-50 border border-indigo-100 px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-medium text-indigo-400 uppercase tracking-wide">
|
||||||
|
{note.source === "mcp" ? "Claude · MCP" : "手动"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">{note.created_at.slice(0, 16)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{note.content}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1094,3 +1094,29 @@ export interface MentorContext {
|
|||||||
|
|
||||||
export const getMentorContext = (projectId: string) =>
|
export const getMentorContext = (projectId: string) =>
|
||||||
invoke<MentorContext>("get_mentor_context", { projectId });
|
invoke<MentorContext>("get_mentor_context", { projectId });
|
||||||
|
|
||||||
|
export const getGroupMentorReport = (groupId: string) =>
|
||||||
|
invoke<string>("get_group_mentor_report", { groupId });
|
||||||
|
|
||||||
|
export interface StartupAlert {
|
||||||
|
project_name: string;
|
||||||
|
alert_type: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStartupAlerts = () =>
|
||||||
|
invoke<StartupAlert[]>("get_startup_alerts");
|
||||||
|
|
||||||
|
export interface ProjectNote {
|
||||||
|
id: number;
|
||||||
|
project_id: string;
|
||||||
|
source: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProjectNotes = (projectId: string) =>
|
||||||
|
invoke<ProjectNote[]>("get_project_notes", { projectId });
|
||||||
|
|
||||||
|
export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
|
||||||
|
invoke<void>("add_project_note", { projectId, content, source });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user