dev-manager-tauri/src/components/mentor/MentorPanel.tsx
lanrtop af88408669 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 子命令
2026-04-04 23:20:09 +09:00

236 lines
11 KiB
TypeScript
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.

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands";
interface Props {
projectId: string;
projectName: string;
onClose: () => void;
}
export function MentorPanel({ projectId, projectName, onClose }: Props) {
const [copied, setCopied] = useState(false);
const { data: ctx, isLoading, isError, error } = useQuery<MentorContext>({
queryKey: ["mentorContext", projectId],
queryFn: () => getMentorContext(projectId),
staleTime: 30000,
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(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-gray-900"></h2>
<p className="text-xs text-gray-400 mt-0.5">{projectName}</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-5">
{isLoading && (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">
</div>
)}
{isError && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
<span></span>
<span>{String(error)}</span>
</div>
)}
{ctx && (
<div className="flex flex-col gap-5">
{/* 本地健康 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
<div className="flex flex-col gap-1.5">
<HealthRow
ok={ctx.health.path_valid}
label="本地路径"
ok_text="有效"
fail_text="路径不存在"
/>
<HealthRow
ok={ctx.health.has_git}
label="Git 仓库"
ok_text="已初始化"
fail_text="未找到 .git 目录"
warn={!ctx.health.has_git}
/>
{ctx.health.tech_stack && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0"></span>
<span className="text-gray-700">{ctx.health.tech_stack}</span>
</div>
)}
{ctx.health.last_commit && (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0"></span>
<code className="text-gray-700 font-mono text-[11px] bg-gray-50 px-1.5 py-0.5 rounded">
{ctx.health.last_commit}
</code>
</div>
)}
</div>
</section>
{/* 蓝图状态 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
{ctx.blueprint_summary ? (
ctx.blueprint_summary.module_count === 0 ? (
<p className="text-xs text-gray-400"></p>
) : (
<div className="grid grid-cols-3 gap-2">
{[
{ label: "模块", value: `${ctx.blueprint_summary.done_count}/${ctx.blueprint_summary.module_count}` },
{ label: "任务", value: `${ctx.blueprint_summary.done_tasks}/${ctx.blueprint_summary.total_tasks}` },
{ label: "可派发", value: String(ctx.blueprint_summary.dispatchable) },
{ label: "进行中", value: String(ctx.blueprint_summary.in_progress) },
{ label: "已规划", value: String(ctx.blueprint_summary.planned) },
].map(({ label, value }) => (
<div key={label} className="bg-gray-50 rounded-lg px-3 py-2 flex flex-col gap-0.5">
<span className="text-[11px] text-gray-400">{label}</span>
<span className="text-sm font-semibold text-gray-800">{value}</span>
</div>
))}
</div>
)
) : (
<p className="text-xs text-gray-400"> .blueprint/manifest.yaml</p>
)}
</section>
{/* 近期错误日志 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"></h3>
{ctx.recent_errors.length === 0 ? (
<p className="text-xs text-gray-400"> </p>
) : (
<ul className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{ctx.recent_errors.map((e, i) => (
<li key={i} className="flex gap-2 text-[11px]">
<span className={`shrink-0 font-semibold ${e.level === "error" ? "text-red-500" : "text-yellow-500"}`}>
{e.level === "error" ? "❌" : "⚠️"}
</span>
<span className="text-gray-400 shrink-0 font-mono">{e.ts.slice(0, 19)}</span>
<span className="text-gray-500 shrink-0">[{e.category}]</span>
<span className="text-gray-700 break-all">{e.message}</span>
</li>
))}
</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>
{/* Footer */}
{ctx && (
<div className="px-6 py-4 border-t border-gray-100 shrink-0 flex items-center justify-between">
<p className="text-xs text-gray-400"> Claude</p>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors shrink-0"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
</>
) : (
<>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
</svg>
</>
)}
</button>
</div>
)}
</div>
</div>
);
}
// ── 辅助组件 ──────────────────────────────────────────────────────────────────
function HealthRow({
ok,
label,
ok_text,
fail_text,
warn,
}: {
ok: boolean;
label: string;
ok_text: string;
fail_text: string;
warn?: boolean;
}) {
const icon = ok ? "✅" : warn ? "⚠️" : "❌";
const textColor = ok ? "text-green-700" : warn ? "text-yellow-700" : "text-red-700";
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400 w-16 shrink-0">{label}</span>
<span>{icon}</span>
<span className={textColor}>{ok ? ok_text : fail_text}</span>
</div>
);
}