feat(flywheel-L0): F2 飞轮面板 Git 健康度区块 + 全量导入按钮

- FlywheelPanel 新增「Git 执行质量」卡片:表格展示 commits/规范化率/返工率/TOP scope
- 「全量导入」按钮对所有接入项目触发 ingest_full_git_history,完成后刷新健康度数据
- 颜色编码:规范化率 ≥80% 绿/≥50% 黄/其余红,返工率 >20% 红/>10% 黄
This commit is contained in:
lanrtop 2026-06-29 20:58:05 +09:00
parent 9c2064e3d0
commit 532e28c421
2 changed files with 99 additions and 8 deletions

View File

@ -110,14 +110,14 @@
---
### 📋 F1. Git 健康度后端——全量历史导入 + 跨项目聚合
- status: todo
### F1. Git 健康度后端——全量历史导入 + 跨项目聚合
- status: done
- complexity: M
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: 新增 `ingest_full_git_history(project_id, project_path)` 命令(最多 500 条INSERT OR IGNORE 幂等);新增 `get_project_commit_health(project_ids)` 命令返回各项目 conventional_rate/rework_rate/top_scopes单元测试覆盖 ingest 幂等性和 health 聚合
### 📋 F2. 飞轮面板——Git 健康度区块 + 全量导入按钮
- status: todo
### F2. 飞轮面板——Git 健康度区块 + 全量导入按钮
- status: done
- complexity: M
- depends: F1
- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx

View File

@ -43,6 +43,9 @@ import {
type TemplateResult,
type CommitStats,
type UserConventionsStatus,
type ProjectCommitHealth,
ingestFullGitHistory,
getProjectCommitHealth,
} from "../../lib/commands";
interface Props {
@ -1660,23 +1663,34 @@ function FlywheelPanel({ onClose }: { onClose: () => void }) {
queryFn: () => getProjects(),
});
const { projectPaths, projectsMap } = useMemo(() => {
const { projectPaths, projectsMap, projectsWithPath } = useMemo(() => {
const paths = (projects ?? [])
.map((p) => p.win_path || p.wsl_path || "")
.filter(Boolean);
const map: Record<string, string> = {};
const withPath: Array<{ id: string; name: string; path: string }> = [];
for (const p of (projects ?? [])) {
const path = p.win_path || p.wsl_path || "";
if (!path) continue;
// 用炼境项目名做 key
withPath.push({ id: p.id, name: p.name, path });
map[p.name] = path;
// 同时用路径末尾目录名做备用 keyusage.json 里存的是目录名)
const dirName = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
if (dirName && !map[dirName]) map[dirName] = path;
}
return { projectPaths: paths, projectsMap: map };
return { projectPaths: paths, projectsMap: map, projectsWithPath: withPath };
}, [projects]);
const projectIds = useMemo(() => projectsWithPath.map((p) => p.id), [projectsWithPath]);
const { data: commitHealth, refetch: refetchHealth } = useQuery<ProjectCommitHealth[]>({
queryKey: ["commit-health", projectIds],
queryFn: () => getProjectCommitHealth(projectIds),
enabled: projectIds.length > 0,
});
const [ingesting, setIngesting] = useState(false);
const [ingestSummary, setIngestSummary] = useState<string | null>(null);
const { data: stats, isLoading } = useQuery<FlywheelStats>({
queryKey: ["flywheel-stats", projectPaths],
queryFn: () => getFlywheelStats(projectPaths),
@ -1690,6 +1704,25 @@ function FlywheelPanel({ onClose }: { onClose: () => void }) {
const [archiving, setArchiving] = useState<string | null>(null);
const [pendingArchive, setPendingArchive] = useState<{ path: string; name: string } | null>(null);
const handleIngestAll = async () => {
setIngesting(true);
setIngestSummary(null);
let totalImported = 0;
let totalSkipped = 0;
for (const { id, path } of projectsWithPath) {
try {
const r = await ingestFullGitHistory(id, path);
totalImported += r.imported;
totalSkipped += r.skipped;
} catch {
// 单个项目失败不中断其他项目
}
}
await refetchHealth();
setIngestSummary(`新导入 ${totalImported} 条,跳过 ${totalSkipped}`);
setIngesting(false);
};
const handleGenerateMuhe = async () => {
if (!stats) return;
setGenerating(true);
@ -1894,6 +1927,64 @@ function FlywheelPanel({ onClose }: { onClose: () => void }) {
</div>
)}
{/* Git 执行质量 */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Git </p>
<button
onClick={handleIngestAll}
disabled={ingesting || projectsWithPath.length === 0}
className="text-[10px] px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 border border-indigo-200 hover:bg-indigo-100 disabled:opacity-40 transition-colors"
>
{ingesting ? "导入中…" : "全量导入"}
</button>
</div>
{ingestSummary && (
<p className="text-[10px] text-green-600">{ingestSummary}</p>
)}
{commitHealth && commitHealth.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-[10px]">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left pb-1 font-medium"></th>
<th className="text-right pb-1 font-medium">commits</th>
<th className="text-right pb-1 font-medium"></th>
<th className="text-right pb-1 font-medium"></th>
<th className="text-left pb-1 font-medium pl-2">TOP scope</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{commitHealth.map((h) => {
const proj = projectsWithPath.find((p) => p.id === h.project_id);
return (
<tr key={h.project_id} className="hover:bg-gray-50">
<td className="py-1 text-gray-700 truncate max-w-[80px]" title={proj?.name}>
{proj?.name ?? h.project_id}
</td>
<td className="py-1 text-right font-mono text-gray-500">{h.total_commits}</td>
<td className={`py-1 text-right font-mono font-semibold ${h.conventional_rate >= 0.8 ? "text-green-600" : h.conventional_rate >= 0.5 ? "text-amber-500" : "text-red-500"}`}>
{Math.round(h.conventional_rate * 100)}%
</td>
<td className={`py-1 text-right font-mono ${h.rework_rate > 0.2 ? "text-red-500" : h.rework_rate > 0.1 ? "text-amber-500" : "text-gray-500"}`}>
{Math.round(h.rework_rate * 100)}%
</td>
<td className="py-1 pl-2 text-gray-400 truncate max-w-[100px]">
{h.top_scopes.slice(0, 2).map((s) => `${s.scope}(${s.commit_count})`).join(" ")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-[10px] text-gray-400 italic">
"全量导入" git
</p>
)}
</div>
{/* 梦核分析 */}
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider"></p>