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 rusqlite::params;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ── 返回结构 ──────────────────────────────────────────────────────────────────
|
||||
@ -423,3 +423,311 @@ fn build_mentor_markdown(
|
||||
|
||||
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
|
||||
);
|
||||
").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)]
|
||||
|
||||
@ -5,7 +5,7 @@ mod mcp_server;
|
||||
mod models;
|
||||
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)]
|
||||
pub fn run() {
|
||||
@ -45,6 +45,11 @@ pub fn run() {
|
||||
let mcp_port = mcp_server::read_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 quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
|
||||
@ -232,6 +237,10 @@ pub fn run() {
|
||||
save_canvas_state,
|
||||
// mentor
|
||||
get_mentor_context,
|
||||
get_project_notes,
|
||||
add_project_note,
|
||||
get_group_mentor_report,
|
||||
get_startup_alerts,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -162,6 +162,24 @@ fn tools_list_result() -> Value {
|
||||
},
|
||||
"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("");
|
||||
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)),
|
||||
})
|
||||
.await;
|
||||
@ -454,3 +477,28 @@ fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String
|
||||
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
|
||||
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())
|
||||
}
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@ -10,7 +10,7 @@ import { HealthPage } from "./components/health/HealthPage";
|
||||
import { Sidebar, type Page } from "./components/layout/Sidebar";
|
||||
import { Toast } from "./components/ui/Toast";
|
||||
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 { useUIStore } from "./store/ui";
|
||||
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() {
|
||||
const [page, setPage] = useState<Page>("overview");
|
||||
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
|
||||
const [alerts, setAlerts] = useState<StartupAlert[]>([]);
|
||||
const [alertsDismissed, setAlertsDismissed] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
|
||||
@ -36,6 +38,17 @@ export default function App() {
|
||||
.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 () => {
|
||||
await githubLogout();
|
||||
qc.clear();
|
||||
@ -64,13 +77,32 @@ export default function App() {
|
||||
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">
|
||||
{alerts.length > 0 && !alertsDismissed && (
|
||||
<div className="shrink-0 bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-start gap-3">
|
||||
<span className="text-amber-500 shrink-0 mt-0.5">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-amber-800">项目需要关注:</span>
|
||||
<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>
|
||||
|
||||
<ProjectModal />
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
deleteProject, getGroups, getGroupMaps, getProjects, getSpaces,
|
||||
deleteGroup, addGroupMember, removeGroupMember,
|
||||
createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate,
|
||||
getGroupMentorReport,
|
||||
type ProductGroup, type Project, type ProjectTemplate,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
@ -211,6 +212,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
|
||||
const [memberTarget, setMemberTarget] = useState<ProductGroup | 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({
|
||||
queryKey: ["groups", spaceId],
|
||||
@ -281,6 +296,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
projectId: string;
|
||||
@ -18,6 +18,13 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: notes = [] } = useQuery<ProjectNote[]>({
|
||||
queryKey: ["projectNotes", projectId],
|
||||
queryFn: () => getProjectNotes(projectId),
|
||||
staleTime: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!ctx) return;
|
||||
navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {});
|
||||
@ -143,6 +150,28 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@ -1094,3 +1094,29 @@ export interface MentorContext {
|
||||
|
||||
export const getMentorContext = (projectId: string) =>
|
||||
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