N3 洞察页三 tab(飞轮/健康中心/全局治理):
- FlywheelPanel(版本队列/阻塞/停转归档/Git质量/梦核)整体迁出 BlueprintModal
——跨项目内容不再寄生单项目弹窗,BlueprintModal 减重 414 行
- CONVENTIONS 规则来源 + 应用梦核建议迁至全局治理 tab(影响所有项目的操作归全局层)
- BlueprintModal 侧板只留单项目内容(模块详情/余票/可派发/项目治理)
N4 Gitea 层级归位:实例管理(全局配置)拆为 GiteaInstancesSection 挂设置页;
项目绑定+webhook 留治理面板,无实例时提示去设置页添加;共用 queryKey 缓存
N5 仓库注册表并入 项目→管理→仓库 tab(RegistryTab 复用),Sidebar 底部入口
与弹窗死壳移除;总览页更名对齐"跨项目行动项"定位
至此 ui-restructure N1-N5 全部完成:三条公理(名实一致/层级对齐/频率分层)落地。
零后端变更,typecheck 全绿。
424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
// 飞轮健康面板(跨项目):版本队列对比 / 阻塞原因 / 停转与休眠归档 / Git 执行质量 / 梦核分析
|
||
// 2026-07-02 ui-restructure N3:从 BlueprintModal 侧板迁出为洞察页一级内容(跨项目内容不属于单项目弹窗)
|
||
import { useState, useMemo } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
getProjects,
|
||
getFlywheelStats,
|
||
generateMuhePrompt,
|
||
archiveProjectDocs,
|
||
ingestFullGitHistory,
|
||
getAllCommitHealth,
|
||
type Project,
|
||
type FlywheelStats,
|
||
type StalledProject,
|
||
type CommitHealthWithName,
|
||
} from "../../lib/commands";
|
||
|
||
function StalledProjectRow({ p, projectsMap, onArchive }: {
|
||
p: StalledProject;
|
||
projectsMap: Record<string, string>; // name → path
|
||
onArchive?: (path: string, name: string) => void;
|
||
}) {
|
||
const path = projectsMap[p.name];
|
||
return (
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-amber-700 truncate">· {p.name}</p>
|
||
<p className="text-[10px] text-amber-400">最后活跃: {p.last_active}</p>
|
||
</div>
|
||
{onArchive && path && (
|
||
<button
|
||
onClick={() => onArchive(path, p.name)}
|
||
className="text-[10px] text-amber-600 bg-amber-100 hover:bg-amber-200 px-1.5 py-0.5 rounded shrink-0 transition-colors"
|
||
>
|
||
归档
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricRow({ label, value, good, invert = false }: {
|
||
label: string;
|
||
value: string;
|
||
good: boolean;
|
||
invert?: boolean;
|
||
}) {
|
||
const isGood = invert ? !good : good;
|
||
return (
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[10px] text-gray-400">{label}</span>
|
||
<span className={`text-[10px] font-mono font-semibold ${isGood ? "text-green-600" : "text-yellow-600"}`}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FlywheelPanel() {
|
||
const { data: projects } = useQuery<Project[]>({
|
||
queryKey: ["projects-for-flywheel"],
|
||
queryFn: () => getProjects(),
|
||
});
|
||
|
||
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;
|
||
withPath.push({ id: p.id, name: p.name, path });
|
||
map[p.name] = path;
|
||
const dirName = path.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
||
if (dirName && !map[dirName]) map[dirName] = path;
|
||
}
|
||
return { projectPaths: paths, projectsMap: map, projectsWithPath: withPath };
|
||
}, [projects]);
|
||
|
||
const { data: commitHealth, refetch: refetchHealth } = useQuery<CommitHealthWithName[]>({
|
||
queryKey: ["commit-health-all"],
|
||
queryFn: () => getAllCommitHealth(),
|
||
});
|
||
|
||
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),
|
||
enabled: projectPaths.length > 0,
|
||
});
|
||
|
||
const [muhePrompt, setMuhePrompt] = useState("");
|
||
const [generating, setGenerating] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const [archiveMsg, setArchiveMsg] = useState<string | null>(null);
|
||
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);
|
||
try {
|
||
const result = await generateMuhePrompt(stats);
|
||
setMuhePrompt(result);
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(muhePrompt).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
};
|
||
|
||
const handleArchiveConfirm = async () => {
|
||
if (!pendingArchive) return;
|
||
const { path, name } = pendingArchive;
|
||
setPendingArchive(null);
|
||
setArchiving(name);
|
||
setArchiveMsg(null);
|
||
try {
|
||
const result = await archiveProjectDocs(path);
|
||
setArchiveMsg(`✓ 已归档 ${result.archived_files.length} 个文件 → ${result.archive_dir}`);
|
||
} catch (e) {
|
||
setArchiveMsg(`归档失败: ${String(e)}`);
|
||
} finally {
|
||
setArchiving(null);
|
||
}
|
||
};
|
||
|
||
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`;
|
||
|
||
// 去重——90天休眠的项目不在60天停转里重复展示
|
||
const stalledOnlyProjects = (stats?.stalled_projects ?? []).filter(
|
||
(p) => !(stats?.ai_doc_stale_projects ?? []).some((q) => q.name === p.name)
|
||
);
|
||
|
||
return (
|
||
<div className="p-4 space-y-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-800">飞轮健康</h3>
|
||
{stats && (
|
||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||
{stats.projects_with_data}/{stats.total_projects_scanned} 个项目有数据
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<p className="text-xs text-gray-400 text-center py-6">聚合中…</p>
|
||
) : !stats || stats.projects_with_data === 0 ? (
|
||
<div className="text-center py-6 space-y-2">
|
||
<p className="text-xs text-gray-400">暂无飞轮数据</p>
|
||
<p className="text-[11px] text-gray-300">打开各项目蓝图后数据将自动积累</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* CONVENTIONS 版本队列对比 */}
|
||
{stats.cohorts.length > 0 && (
|
||
<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>
|
||
<div className="space-y-3">
|
||
{stats.cohorts.map((c) => (
|
||
<div key={c.conventions_version} className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-mono text-gray-600">v{c.conventions_version}</span>
|
||
<span className="text-[10px] text-gray-400">{c.project_count} 个项目</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||
<MetricRow label="可派发率" value={fmtPct(c.dispatchable_rate)} good={c.dispatchable_rate > 0.3} />
|
||
<MetricRow label="任务完成率" value={fmtPct(c.completion_rate)} good={c.completion_rate > 0.5} />
|
||
<MetricRow label="飞轮持续率" value={fmtPct(c.continuity_rate)} good={c.continuity_rate > 0.6} />
|
||
<MetricRow label="阻塞密度" value={fmtPct(c.blocked_density)} good={c.blocked_density < 0.1} invert />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TOP blocked reasons */}
|
||
{stats.top_blocked_reasons.length > 0 && (
|
||
<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">TOP 阻塞原因</p>
|
||
<ul className="space-y-1">
|
||
{stats.top_blocked_reasons.map((r, i) => (
|
||
<li key={i} className="flex items-start gap-2">
|
||
<span className="text-[10px] text-gray-400 shrink-0 mt-0.5">#{i + 1}</span>
|
||
<span className="text-[11px] text-red-600 flex-1">{r.reason}</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">×{r.count}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* 仅展示"停转但未到90天"的项目 */}
|
||
{stalledOnlyProjects.length > 0 && (
|
||
<div className="bg-amber-50 rounded-lg border border-amber-100 p-3 space-y-1.5">
|
||
<p className="text-[10px] font-semibold text-amber-500 uppercase tracking-wider">
|
||
飞轮停转(60–90天无快照)
|
||
</p>
|
||
{stalledOnlyProjects.map((p, i) => (
|
||
<StalledProjectRow key={i} p={p} projectsMap={projectsMap} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* AI文档休眠项目(≥90天) */}
|
||
{stats.ai_doc_stale_projects.length > 0 && (
|
||
<div className="bg-red-50 rounded-lg border border-red-100 p-3 space-y-1.5">
|
||
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider">
|
||
AI文档休眠(90天+无快照)
|
||
</p>
|
||
<p className="text-[10px] text-red-400 mb-1">
|
||
建议归档 .blueprint/modules/,清空 Claude 上下文负担。
|
||
</p>
|
||
{pendingArchive ? (
|
||
<div className="bg-red-100 border border-red-200 rounded p-2.5 space-y-2">
|
||
<p className="text-[11px] text-red-700 font-semibold">
|
||
确认归档「{pendingArchive.name}」?
|
||
</p>
|
||
<p className="text-[10px] text-red-500 leading-relaxed">
|
||
将删除 .blueprint/modules/*.md,文件会备份到 .blueprint/archive/,但操作本身不可一键撤销。
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleArchiveConfirm}
|
||
disabled={!!archiving}
|
||
className="flex-1 py-1 rounded bg-red-600 text-white text-[11px] hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
确认归档
|
||
</button>
|
||
<button
|
||
onClick={() => setPendingArchive(null)}
|
||
className="flex-1 py-1 rounded border border-red-200 text-red-500 text-[11px] hover:bg-red-50 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
stats.ai_doc_stale_projects.map((p, i) => (
|
||
<StalledProjectRow
|
||
key={i}
|
||
p={p}
|
||
projectsMap={projectsMap}
|
||
onArchive={archiving ? undefined : (path, name) => setPendingArchive({ path, name })}
|
||
/>
|
||
))
|
||
)}
|
||
{archiving && (
|
||
<p className="text-[10px] text-red-400">归档 {archiving} 中…</p>
|
||
)}
|
||
{archiveMsg && (
|
||
<p className={`text-[10px] break-all ${archiveMsg.startsWith("✓") ? "text-green-600" : "text-red-600"}`}>
|
||
{archiveMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 迭代跳变项目 */}
|
||
{(stats.iteration_jumps ?? []).length > 0 && (
|
||
<div className="bg-purple-50 rounded-lg border border-purple-100 p-3 space-y-1.5">
|
||
<p className="text-[10px] font-semibold text-purple-500 uppercase tracking-wider">
|
||
迭代跳变(主动重构,完成率重置)
|
||
</p>
|
||
{(stats.iteration_jumps ?? []).map((j, i) => (
|
||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||
<span className="text-gray-600 truncate flex-1">{j.project}</span>
|
||
<span className="text-purple-400 shrink-0">迭代 {j.from_iteration}→{j.to_iteration}</span>
|
||
<span className={`shrink-0 font-mono ${j.rate_after < j.rate_before - 0.3 ? "text-amber-500 font-semibold" : "text-gray-400"}`}>
|
||
{(j.rate_before * 100).toFixed(0)}%→{(j.rate_after * 100).toFixed(0)}%
|
||
{j.rate_after < j.rate_before - 0.3 && " ⚠️"}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 停滞模块 */}
|
||
{(stats.stalled_modules ?? []).length > 0 && (
|
||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3 space-y-1.5">
|
||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||
停滞模块(in_progress 但无可推进任务)
|
||
</p>
|
||
<ul className="space-y-0.5">
|
||
{(stats.stalled_modules ?? []).map((m, i) => (
|
||
<li key={i} className="text-[11px] text-gray-500 font-mono">{m}</li>
|
||
))}
|
||
</ul>
|
||
</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) => {
|
||
return (
|
||
<tr key={h.project_id} className="hover:bg-gray-50">
|
||
<td className="py-1 text-gray-700 truncate max-w-[80px]" title={h.project_name}>
|
||
{h.project_name}
|
||
</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>
|
||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||
生成包含以上数据的提示词,交给 Claude Opus 做固化/遗忘判断。
|
||
</p>
|
||
{!muhePrompt ? (
|
||
<button
|
||
onClick={handleGenerateMuhe}
|
||
disabled={generating}
|
||
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{generating ? "生成中…" : "生成梦核提示词"}
|
||
</button>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<textarea
|
||
readOnly
|
||
value={muhePrompt}
|
||
rows={10}
|
||
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2.5 resize-none outline-none"
|
||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||
/>
|
||
<button
|
||
onClick={handleCopy}
|
||
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
|
||
>
|
||
{copied ? "已复制 ✓" : "复制提示词"}
|
||
</button>
|
||
<button
|
||
onClick={() => setMuhePrompt("")}
|
||
className="w-full py-1 rounded-lg text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||
>
|
||
重新生成
|
||
</button>
|
||
<p className="text-[10px] text-gray-400 text-center leading-relaxed">
|
||
复制 → 粘贴给 Claude Opus → 建议可在「全局治理」tab 直接应用(写入并同步所有项目)
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|