dev-manager-tauri/src/components/insight/FlywheelPanel.tsx
lanrtop bf697b5e28 feat(ui): 功能区重构阶段二——洞察页 + 全局/项目层级归位(N3/N4/N5)
N3 洞察页三 tab(飞轮/健康中心/全局治理):
   - FlywheelPanel(版本队列/阻塞/停转归档/Git质量/梦核)整体迁出 BlueprintModal
     ——跨项目内容不再寄生单项目弹窗,BlueprintModal 减重 414 行
   - CONVENTIONS 规则来源 + 应用梦核建议迁至全局治理 tab(影响所有项目的操作归全局层)
   - BlueprintModal 侧板只留单项目内容(模块详情/余票/可派发/项目治理)
N4 Gitea 层级归位:实例管理(全局配置)拆为 GiteaInstancesSection 挂设置页;
   项目绑定+webhook 留治理面板,无实例时提示去设置页添加;共用 queryKey 缓存
N5 仓库注册表并入 项目→管理→仓库 tab(RegistryTab 复用),Sidebar 底部入口
   与弹窗死壳移除;总览页更名对齐"跨项目行动项"定位

至此 ui-restructure N1-N5 全部完成:三条公理(名实一致/层级对齐/频率分层)落地。
零后端变更,typecheck 全绿。
2026-07-02 14:51:43 +09:00

424 lines
18 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.

// 飞轮健康面板(跨项目):版本队列对比 / 阻塞原因 / 停转与休眠归档 / 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">
6090
</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>
);
}