dev-manager-tauri/src/components/dashboard/BlueprintModal.tsx
lanrtop 23b109119a fix: 已有蓝图时禁用「初始化蓝图」按钮
避免用户误用初始化覆盖已有蓝图,引导使用「更新蓝图描绘」。
disabled 状态加 tooltip 提示原因。
2026-04-04 23:33:13 +09:00

1622 lines
67 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ReactFlow,
Background,
Controls,
type Node,
type Edge,
type NodeTypes,
Handle,
Position,
MarkerType,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
getBlueprint,
getBlueprintStatus,
syncBlueprintRules,
generateBlueprintPrompt,
getProjects,
getFlywheelStats,
generateMuhePrompt,
archiveProjectDocs,
type BlueprintData,
type BlueprintModule,
type BlueprintTask,
type BlueprintArea,
type BlueprintStatus,
type SyncResult,
type Project,
type FlywheelStats,
type StalledProject,
} from "../../lib/commands";
interface Props {
projectName: string;
projectPath: string;
onClose: () => void;
}
// ── 状态色标 ─────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
done: { color: "#22C55E", bg: "#F0FDF4", label: "已完成" },
in_progress: { color: "#3B82F6", bg: "#EFF6FF", label: "进行中" },
planned: { color: "#EAB308", bg: "#FEFCE8", label: "已规划" },
concept: { color: "#9CA3AF", bg: "#F9FAFB", label: "构思中" },
blocked: { color: "#EF4444", bg: "#FEF2F2", label: "受阻" },
};
const TASK_PREFIX: Record<string, string> = {
done: "✅",
in_progress: "🔵",
todo: "📋",
concept: "💭",
blocked: "🔴",
locked: "🔒",
};
// ── 领域分组节点 ─────────────────────────────────────────────────────────────
function AreaNode({ data }: { data: { label: string; color: string } }) {
return (
<div
className="rounded-xl border-2 border-dashed px-4 pt-2 pb-3 w-full h-full pointer-events-none"
style={{
borderColor: data.color + "60",
background: data.color + "08",
}}
>
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-1 rounded-full" style={{ background: data.color }} />
<span className="text-[11px] font-bold uppercase tracking-wider" style={{ color: data.color }}>
{data.label}
</span>
</div>
</div>
);
}
// ── 模块节点 ─────────────────────────────────────────────────────────────────
function ModuleNode({ data }: { data: BlueprintModule & { areaColor?: string; dimmed?: boolean; pinned?: boolean; onClick: () => void; onHover?: (id: string | null) => void } }) {
const cfg = STATUS_CONFIG[data.status] ?? STATUS_CONFIG.concept;
const tasksDone = data.tasks.filter((t) => t.status === "done").length;
const tasksTotal = data.tasks.length;
return (
<div
onClick={data.onClick}
onMouseEnter={() => data.onHover?.(data.id)}
onMouseLeave={() => data.onHover?.(null)}
className="cursor-pointer rounded-lg border-2 shadow-sm px-3 py-2.5 min-w-[160px] max-w-[200px] transition-all hover:shadow-md hover:scale-[1.02]"
style={{
borderColor: data.dimmed ? "#D1D5DB" : cfg.color,
background: data.dimmed ? "#F9FAFB" : cfg.bg,
borderLeftWidth: 4,
borderLeftColor: data.dimmed ? "#E5E7EB" : (data.areaColor ?? cfg.color),
opacity: data.dimmed ? 0.35 : 1,
boxShadow: data.pinned ? undefined : undefined,
animation: data.pinned ? "ssr-glow 2s linear infinite" : undefined,
transition: "opacity 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s",
}}
>
<Handle id="left-t" type="target" position={Position.Left} className="!w-2 !h-2 !-left-1" style={{ background: cfg.color }} />
<Handle id="left-s" type="source" position={Position.Left} className="!w-2 !h-2 !-left-1" style={{ background: cfg.color }} />
<Handle id="right-t" type="target" position={Position.Right} className="!w-2 !h-2 !-right-1" style={{ background: cfg.color }} />
<Handle id="right-s" type="source" position={Position.Right} className="!w-2 !h-2 !-right-1" style={{ background: cfg.color }} />
<Handle id="top-t" type="target" position={Position.Top} className="!w-2 !h-2 !-top-1" style={{ background: cfg.color }} />
<Handle id="top-s" type="source" position={Position.Top} className="!w-2 !h-2 !-top-1" style={{ background: cfg.color }} />
<Handle id="bottom-t" type="target" position={Position.Bottom} className="!w-2 !h-2 !-bottom-1" style={{ background: cfg.color }} />
<Handle id="bottom-s" type="source" position={Position.Bottom} className="!w-2 !h-2 !-bottom-1" style={{ background: cfg.color }} />
<div className="flex items-center gap-1.5 mb-1">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cfg.color }} />
<span className="text-xs font-semibold text-gray-800 truncate">{data.name}</span>
</div>
{/* 进度条 */}
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{ width: `${data.progress}%`, background: cfg.color }}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500">{cfg.label}</span>
{tasksTotal > 0 && (
<span className="text-[10px] text-gray-400">{tasksDone}/{tasksTotal}</span>
)}
</div>
</div>
);
}
const nodeTypes: NodeTypes = {
module: ModuleNode,
area: AreaNode,
};
// ── 布局计算 ─────────────────────────────────────────────────────────────────
const NODE_W = 180;
const NODE_H = 75;
const GAP_X = 80;
const GAP_Y = 60;
const AREA_PAD_X = 30;
const AREA_PAD_Y = 50;
const AREA_GAP = 80;
const COLS_PER_AREA = 4;
function autoLayout(
areas: BlueprintArea[],
modules: BlueprintModule[],
) {
const areaNodes: Node[] = [];
const moduleNodes: Node[] = [];
// 绝对坐标映射,用于计算边的最佳 handle
const absPositions: Record<string, { x: number; y: number }> = {};
let areaOffsetY = 0;
for (const area of areas) {
const areaModules = modules.filter((m) => m.area === area.id);
if (areaModules.length === 0) continue;
const cols = Math.min(areaModules.length, COLS_PER_AREA);
const rows = Math.ceil(areaModules.length / cols);
const areaW = cols * (NODE_W + GAP_X) - GAP_X + AREA_PAD_X * 2;
const areaH = rows * (NODE_H + GAP_Y) - GAP_Y + AREA_PAD_Y + AREA_PAD_X;
areaNodes.push({
id: `area-${area.id}`,
type: "area",
position: { x: 0, y: areaOffsetY },
data: { label: area.name, color: area.color ?? "#6B7280" },
style: { width: areaW, height: areaH },
selectable: false,
draggable: false,
});
areaModules.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const localX = AREA_PAD_X + col * (NODE_W + GAP_X);
const localY = AREA_PAD_Y + row * (NODE_H + GAP_Y);
moduleNodes.push({
id: m.id,
type: "module",
position: { x: localX, y: localY },
parentId: `area-${area.id}`,
extent: "parent" as const,
data: { ...m, areaColor: area.color },
});
absPositions[m.id] = { x: localX, y: areaOffsetY + localY };
});
areaOffsetY += areaH + AREA_GAP;
}
return { areaNodes, moduleNodes, absPositions };
}
/** 根据源/目标的相对位置,选出最短路径的 handle 对 */
function pickHandles(
srcPos: { x: number; y: number },
tgtPos: { x: number; y: number },
): { sourceHandle: string; targetHandle: string } {
const dx = tgtPos.x - srcPos.x;
const dy = tgtPos.y - srcPos.y;
// 以节点中心为基准,判断主要方向
if (Math.abs(dx) >= Math.abs(dy)) {
// 水平为主
if (dx > 0) {
// target 在 source 右边source 右出 → target 左进
return { sourceHandle: "right-s", targetHandle: "left-t" };
} else {
// target 在 source 左边source 左出 → target 右进
return { sourceHandle: "left-s", targetHandle: "right-t" };
}
} else {
// 垂直为主
if (dy > 0) {
// target 在 source 下方source 下出 → target 上进
return { sourceHandle: "bottom-s", targetHandle: "top-t" };
} else {
// target 在 source 上方source 上出 → target 下进
return { sourceHandle: "top-s", targetHandle: "bottom-t" };
}
}
}
// ── 主组件 ───────────────────────────────────────────────────────────────────
type SidePanel = { type: "module"; module: BlueprintModule } | { type: "next" } | { type: "remaining" } | { type: "governance" } | { type: "flywheel" } | null;
export function BlueprintModal({ projectName, projectPath, onClose }: Props) {
const [sidePanel, setSidePanel] = useState<SidePanel>(null);
const [hoveredModuleId, setHoveredModuleId] = useState<string | null>(null);
const [pinnedModuleId, setPinnedModuleId] = useState<string | null>(null);
// 实际高亮的模块pin 优先,否则用 hover
const activeModuleId = pinnedModuleId ?? hoveredModuleId;
const { data: blueprint, isLoading, error } = useQuery<BlueprintData | null>({
queryKey: ["blueprint", projectPath],
queryFn: () => getBlueprint(projectPath),
});
const { data: bpStatus } = useQuery<BlueprintStatus>({
queryKey: ["bp-status", projectPath],
queryFn: () => getBlueprintStatus(projectPath),
enabled: !!projectPath,
});
const handleNodeClick = useCallback((mod: BlueprintModule) => {
setSidePanel({ type: "module", module: mod });
setPinnedModuleId((prev) => (prev === mod.id ? null : mod.id));
}, []);
const handleNodeHover = useCallback((id: string | null) => {
setHoveredModuleId(id);
}, []);
// 布局(稳定,只在 blueprint 变化时重建)
const layout = useMemo(() => {
if (!blueprint) return null;
return autoLayout(blueprint.manifest.areas, blueprint.manifest.modules);
}, [blueprint]);
// 关联模块集合(仅 pin 时生效hover 不触发节点淡化)
const connectedIds = useMemo(() => {
if (!pinnedModuleId || !blueprint) return null;
const ids = new Set<string>([pinnedModuleId]);
for (const e of blueprint.manifest.edges) {
if (e.from === pinnedModuleId) ids.add(e.to);
if (e.to === pinnedModuleId) ids.add(e.from);
}
return ids;
}, [pinnedModuleId, blueprint]);
// nodes 依赖 layout + connectedIds改 dimmed 标记,不改位置)
const { nodes, absPositions } = useMemo(() => {
if (!blueprint || !layout) return { nodes: [] as Node[], absPositions: {} as Record<string, { x: number; y: number }> };
const { areaNodes, moduleNodes, absPositions: pos } = layout;
const flowModuleNodes = moduleNodes.map((n) => {
const mod = blueprint.manifest.modules.find((m) => m.id === n.id);
const dimmed = connectedIds != null && !connectedIds.has(n.id);
const pinned = pinnedModuleId === n.id;
return {
...n,
data: { ...n.data, onClick: () => handleNodeClick(mod!), onHover: handleNodeHover, dimmed, pinned },
};
});
// area 节点也淡化
const flowAreaNodes = areaNodes.map((n) => {
const areaId = n.id.replace("area-", "");
const hasConnected = connectedIds == null || blueprint.manifest.modules.some(
(m) => m.area === areaId && connectedIds.has(m.id),
);
return {
...n,
style: { ...n.style, opacity: hasConnected ? 1 : 0.3, transition: "opacity 0.2s" },
};
});
return { nodes: [...flowAreaNodes, ...flowModuleNodes] as Node[], absPositions: pos };
}, [blueprint, layout, connectedIds, pinnedModuleId, handleNodeClick, handleNodeHover]);
// edges 单独 memohover 只重建边的样式
const edges = useMemo(() => {
if (!blueprint) return [] as Edge[];
return blueprint.manifest.edges.map((e, i) => {
const isDep = e.edge_type === "dependency";
const isRelated = activeModuleId != null && (e.from === activeModuleId || e.to === activeModuleId);
const dimmed = activeModuleId != null && !isRelated;
// 根据模块相对位置选最短路径 handle
const srcPos = absPositions[e.from];
const tgtPos = absPositions[e.to];
const handles = srcPos && tgtPos ? pickHandles(srcPos, tgtPos) : { sourceHandle: "right-s", targetHandle: "left-t" };
return {
id: `e-${i}`,
source: e.from,
target: e.to,
sourceHandle: handles.sourceHandle,
targetHandle: handles.targetHandle,
type: "smoothstep",
animated: isRelated && isDep,
style: {
stroke: isRelated
? (isDep ? "#2563EB" : "#6366F1")
: (isDep ? "#3B82F6" : "#94A3B8"),
strokeWidth: isRelated ? (isDep ? 3.5 : 2.5) : (isDep ? 2.5 : 1.5),
strokeDasharray: isDep ? undefined : "6 4",
opacity: dimmed ? 0.1 : isRelated ? 1 : (isDep ? 0.7 : 0.45),
transition: "opacity 0.2s, stroke-width 0.2s",
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isRelated
? (isDep ? "#2563EB" : "#6366F1")
: (isDep ? "#3B82F6" : "#94A3B8"),
width: isDep ? 18 : 14,
height: isDep ? 14 : 10,
},
zIndex: isRelated ? 10 : 0,
};
});
}, [blueprint, activeModuleId, absPositions]);
const stats = blueprint?.stats;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<style>{`
@keyframes ssr-glow {
0% { box-shadow: 0 0 0 2px #FFD700, 0 0 12px #FFD700, 0 0 30px #FFD70060; }
25% { box-shadow: 0 0 0 2px #A855F7, 0 0 12px #A855F7, 0 0 30px #A855F760; }
50% { box-shadow: 0 0 0 2px #06B6D4, 0 0 12px #06B6D4, 0 0 30px #06B6D460; }
75% { box-shadow: 0 0 0 2px #F43F5E, 0 0 12px #F43F5E, 0 0 30px #F43F5E60; }
100% { box-shadow: 0 0 0 2px #FFD700, 0 0 12px #FFD700, 0 0 30px #FFD70060; }
}
`}</style>
<div className="bg-white rounded-xl shadow-2xl w-[95vw] max-w-[1200px] h-[85vh] flex flex-col">
{/* 标题栏 */}
<div className="px-5 py-3 border-b border-gray-200 flex items-center justify-between shrink-0">
<div>
<h2 className="text-sm font-semibold text-gray-800"></h2>
<p className="text-xs text-gray-400 mt-0.5">
{projectName}
{blueprint?.manifest.updated && (
<span className="ml-2 text-gray-300"> {blueprint.manifest.updated}</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
sidePanel?.type === "flywheel"
? "text-white bg-emerald-600"
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
}`}
>
</button>
<button
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
className={`text-xs px-2.5 py-1 rounded-lg transition-colors ${
sidePanel?.type === "governance"
? "text-white bg-violet-600"
: "text-violet-600 bg-violet-50 hover:bg-violet-100"
}`}
>
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">
×
</button>
</div>
</div>
{/* 统计条 */}
{stats && (
<div className="px-5 py-2.5 border-b border-gray-100 flex items-center gap-4 shrink-0 flex-wrap">
{blueprint?.manifest.iteration != null && blueprint.manifest.iteration > 1 && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-600 shrink-0 font-medium"
title="当前迭代号(模块重构时递增)"
>
{blueprint.manifest.iteration}
</span>
)}
<StatBadge color="#22C55E" label="已完成" count={stats.done} />
<StatBadge color="#3B82F6" label="进行中" count={stats.in_progress} />
<StatBadge color="#EAB308" label="已规划" count={stats.planned} />
<StatBadge color="#9CA3AF" label="构思中" count={stats.concept} />
{stats.tasks_blocked > 0 && (
<StatBadge color="#EF4444" label="受阻" count={stats.tasks_blocked} />
)}
<div className="h-4 w-px bg-gray-200" />
<span className="text-xs text-gray-500">
{stats.tasks_done}/{stats.total_tasks}
</span>
{stats.total_tasks - stats.tasks_done > 0 && (
<button
onClick={() => setSidePanel((p) => p?.type === "remaining" ? null : { type: "remaining" })}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
sidePanel?.type === "remaining"
? "text-white bg-amber-500"
: "text-amber-600 bg-amber-50 hover:bg-amber-100"
}`}
>
{stats.total_tasks - stats.tasks_done}
</button>
)}
{stats.dispatchable > 0 && (
<button
onClick={() => setSidePanel((p) => p?.type === "next" ? null : { type: "next" })}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
sidePanel?.type === "next"
? "text-white bg-blue-600"
: "text-blue-600 bg-blue-50 hover:bg-blue-100"
}`}
>
📋 {stats.dispatchable}
</button>
)}
{/* 规则 & AI文档 状态指示器 */}
{bpStatus && (
<button
onClick={() => setSidePanel((p) => p?.type === "governance" ? null : { type: "governance" })}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
bpStatus.status === "synced"
? "text-green-600 bg-green-50 hover:bg-green-100"
: "text-yellow-600 bg-yellow-50 hover:bg-yellow-100"
}`}
>
{bpStatus.status === "synced" ? "🟢 规则" : "🟡 规则"}
</button>
)}
<button
onClick={() => setSidePanel((p) => p?.type === "flywheel" ? null : { type: "flywheel" })}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 ${
sidePanel?.type === "flywheel"
? "text-white bg-emerald-600"
: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100"
}`}
title="梦核·记忆巩固 / AI文档归档"
>
AI文档
</button>
{/* 总进度条(固定在右侧) */}
<div className="flex-1" />
{pinnedModuleId && (
<button
onClick={() => { setPinnedModuleId(null); setSidePanel(null); }}
className="text-xs px-2 py-0.5 rounded-full text-gray-500 bg-gray-100 hover:bg-gray-200 transition-colors shrink-0"
>
</button>
)}
<div className="flex items-center gap-2 min-w-[120px] w-[180px] shrink-0">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%` }}
/>
</div>
<span className="text-xs text-gray-400 shrink-0">
{stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%
</span>
</div>
</div>
)}
{/* 图例 */}
{blueprint && (
<div className="px-5 py-1.5 border-b border-gray-50 flex items-center gap-5 shrink-0">
<span className="text-[10px] text-gray-400">:</span>
<div className="flex items-center gap-1.5">
<svg width="24" height="8"><line x1="0" y1="4" x2="24" y2="4" stroke="#3B82F6" strokeWidth="2" /><polygon points="20,1 24,4 20,7" fill="#3B82F6" /></svg>
<span className="text-[10px] text-gray-400"></span>
</div>
<div className="flex items-center gap-1.5">
<svg width="24" height="8"><line x1="0" y1="4" x2="24" y2="4" stroke="#D1D5DB" strokeWidth="1" strokeDasharray="4 3" /><polygon points="20,1 24,4 20,7" fill="#D1D5DB" /></svg>
<span className="text-[10px] text-gray-400"></span>
</div>
<div className="h-3 w-px bg-gray-200" />
{blueprint.manifest.areas.map((a) => (
<div key={a.id} className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded border" style={{ background: (a.color ?? "#6B7280") + "20", borderColor: (a.color ?? "#6B7280") + "60" }} />
<span className="text-[10px] text-gray-400">{a.name}</span>
</div>
))}
</div>
)}
{/* 主体区域 */}
<div className="flex-1 flex min-h-0">
{/* React Flow 画布 */}
<div className="flex-1 relative">
{isLoading ? (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
...
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full gap-3 px-8">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center text-2xl">!</div>
<p className="text-gray-600 text-sm font-medium"></p>
<div className="max-w-md w-full px-4 py-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600 font-mono break-all select-text whitespace-pre-wrap">
{error instanceof Error ? error.message : String(error)}
</p>
</div>
<p className="text-xs text-gray-400 text-center">
.blueprint/manifest.yaml
</p>
</div>
) : !blueprint ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center text-2xl">🗺</div>
<p className="text-gray-400 text-sm"></p>
<p className="text-gray-300 text-xs">
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-gray-500">.blueprint/</code>
</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
>
<Background gap={20} size={1} color="#f0f0f0" />
<Controls position="bottom-left" showInteractive={false} />
</ReactFlow>
)}
</div>
{/* 侧边面板 */}
{sidePanel && (
<div className="w-[340px] border-l border-gray-200 overflow-y-auto shrink-0 bg-gray-50/50">
{sidePanel.type === "module" ? (
<ModuleDetail
module={sidePanel.module}
areas={blueprint?.manifest.areas ?? []}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
) : sidePanel.type === "remaining" ? (
<RemainingTasksPanel
modules={blueprint?.manifest.modules ?? []}
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
) : sidePanel.type === "governance" ? (
<GovernancePanel
projectPath={projectPath}
projectName={projectName}
onClose={() => setSidePanel(null)}
onOpenFlywheel={() => setSidePanel({ type: "flywheel" })}
/>
) : sidePanel.type === "flywheel" ? (
<FlywheelPanel onClose={() => setSidePanel(null)} />
) : (
<NextActionsPanel
modules={blueprint?.manifest.modules ?? []}
projectName={projectName}
onSelectModule={(mod) => { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }}
onClose={() => { setSidePanel(null); setPinnedModuleId(null); }}
/>
)}
</div>
)}
</div>
</div>
</div>
);
}
// ── 统计小标签 ───────────────────────────────────────────────────────────────
function StatBadge({ color, label, count }: { color: string; label: string; count: number }) {
return (
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
<span className="text-xs text-gray-500">{label}</span>
<span className="text-xs font-semibold text-gray-700">{count}</span>
</div>
);
}
// ── 模块详情面板 ─────────────────────────────────────────────────────────────
function ModuleDetail({ module: mod, areas, onClose }: { module: BlueprintModule; areas: BlueprintArea[]; onClose: () => void }) {
const cfg = STATUS_CONFIG[mod.status] ?? STATUS_CONFIG.concept;
const area = areas.find((a) => a.id === mod.area);
const grouped = useMemo(() => {
const groups: Record<string, BlueprintTask[]> = {
done: [],
in_progress: [],
blocked: [],
todo: [],
concept: [],
};
for (const t of mod.tasks) {
const key = groups[t.status] ? t.status : "concept";
groups[key].push(t);
}
return groups;
}, [mod.tasks]);
const groupLabels: Record<string, string> = {
done: "已完成",
in_progress: "进行中",
blocked: "受阻",
todo: "待开发",
concept: "构思中",
};
return (
<div className="p-4 space-y-4">
{/* 标题 */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: cfg.color }} />
<h3 className="text-sm font-semibold text-gray-800">{mod.name}</h3>
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{cfg.label} · {mod.progress}%</span>
{area && (
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: (area.color ?? "#6B7280") + "15", color: area.color ?? "#6B7280" }}
>
{area.name}
</span>
)}
</div>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{/* 进度条 */}
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${mod.progress}%`, background: cfg.color }} />
</div>
{/* 描述 */}
{mod.description && (
<p className="text-xs text-gray-500 leading-relaxed bg-white rounded-lg px-3 py-2 border border-gray-100">
{mod.description}
</p>
)}
{/* 决策记录 */}
{mod.decisions && mod.decisions.length > 0 && (
<div className="bg-amber-50 rounded-lg px-3 py-2 border border-amber-100">
<p className="text-[10px] font-semibold text-amber-600 uppercase tracking-wider mb-1"></p>
<ul className="space-y-1">
{mod.decisions.map((d, i) => (
<li key={i} className="text-xs text-amber-700 flex gap-1.5">
<span className="shrink-0 text-amber-400">·</span>
<span>{d}</span>
</li>
))}
</ul>
</div>
)}
{/* 任务卡列表 */}
{mod.tasks.length > 0 && (
<div className="space-y-3">
{(["blocked", "in_progress", "todo", "concept", "done"] as const).map((status) => {
const tasks = grouped[status];
if (!tasks || tasks.length === 0) return null;
return (
<div key={status}>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5">
{groupLabels[status]} ({tasks.length})
</p>
<div className="space-y-1.5">
{tasks.map((t, i) => (
<TaskCard key={i} task={t} moduleName={mod.name} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 任务卡 ───────────────────────────────────────────────────────────────────
function TaskCard({ task, moduleName }: { task: BlueprintTask; moduleName?: string }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const prefix = task.locked ? "🔒" : (TASK_PREFIX[task.status] ?? "");
const isDispatchable =
task.status === "todo" &&
!task.locked &&
task.files &&
task.acceptance &&
(task.complexity === "S" || task.complexity === "M");
const handleCopyForAgent = (e: React.MouseEvent) => {
e.stopPropagation();
const lines = [
`请完成以下任务(完成后更新 .blueprint/modules/ 中对应的任务卡状态为 ✅ done`,
``,
`## ${task.title}`,
moduleName ? `所属模块: ${moduleName}` : "",
task.complexity ? `复杂度: ${task.complexity}` : "",
``,
task.files ? `### 涉及文件\n${task.files}` : "",
task.acceptance ? `\n### 验收标准\n${task.acceptance}` : "",
task.depends ? `\n### 前置依赖\n${task.depends}` : "",
task.notes ? `\n### 补充说明\n${task.notes}` : "",
].filter(Boolean);
navigator.clipboard.writeText(lines.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div
onClick={() => setExpanded(!expanded)}
className={`rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
task.locked
? "border-gray-200 bg-gray-50/80 hover:bg-gray-100 opacity-60"
: task.status === "blocked"
? "border-red-200 bg-red-50/50 hover:bg-red-50"
: isDispatchable
? "border-blue-200 bg-blue-50/50 hover:bg-blue-50"
: "border-gray-100 bg-white hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs shrink-0">{prefix}</span>
<span className="text-xs text-gray-700 flex-1">{task.title}</span>
{task.complexity && (
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded shrink-0">
{task.complexity}
</span>
)}
{isDispatchable && (
<button
onClick={handleCopyForAgent}
className="text-[10px] text-blue-500 bg-blue-100 px-1.5 py-0.5 rounded shrink-0 hover:bg-blue-200 transition-colors"
>
{copied ? "已复制" : "复制派发"}
</button>
)}
</div>
{/* locked 依赖提示,始终显示 */}
{task.locked && task.depends && (
<div className="mt-1.5 px-2 py-1.5 bg-gray-100 border border-gray-200 rounded text-[11px] text-gray-500">
<span className="font-semibold text-gray-400">:</span> {task.depends}
</div>
)}
{/* blocked_reason 始终显示,不需要展开 */}
{task.status === "blocked" && task.blocked_reason && (
<div className="mt-1.5 px-2 py-1.5 bg-red-50 border border-red-100 rounded text-[11px] text-red-600">
<span className="font-semibold text-red-500">:</span> {task.blocked_reason}
</div>
)}
{expanded && (
<div className="mt-2 pt-2 border-t border-gray-100 space-y-1 text-[11px] text-gray-500">
{task.files && (
<p><span className="text-gray-400">:</span> <code className="font-mono text-gray-600 bg-gray-50 px-1 rounded">{task.files}</code></p>
)}
{task.acceptance && (
<p><span className="text-gray-400">:</span> {task.acceptance}</p>
)}
{task.depends && (
<p><span className="text-gray-400">:</span> {task.depends}</p>
)}
{task.notes && (
<p><span className="text-gray-400">:</span> {task.notes}</p>
)}
</div>
)}
</div>
);
}
// ── 下一步汇总面板 ──────────────────────────────────────────────────────────
function NextActionsPanel({
modules,
projectName,
onSelectModule,
onClose,
}: {
modules: BlueprintModule[];
projectName: string;
onSelectModule: (mod: BlueprintModule) => void;
onClose: () => void;
}) {
const [batchCopied, setBatchCopied] = useState(false);
const handleCopyBatchPrompt = () => {
const prompt = [
`当前项目:${projectName}`,
``,
`读取 .blueprint/manifest.yaml 和所有 .blueprint/modules/*.md`,
`以及 .blueprint/CONVENTIONS.md 了解执行规则。`,
``,
`【处理断点】先检查是否有 🔵 in_progress 任务卡(上次中断的),`,
`若有,读取其 files 字段判断已完成什么、还缺什么,从断点继续。`,
``,
`【批量执行】找出所有 📋 todo 任务卡,按任务卡 depends 字段及 manifest.yaml edges 综合拓扑排序后依次执行:`,
`- 执行前:前缀改为 🔵信息1·加锁`,
`- 执行依据:任务卡的 files 字段(涉及哪些文件)+ acceptance 字段(验收标准)`,
`- 完成后:前缀改为 ✅信息2·解锁若模块所有任务卡均完成模块 status 改为 done`,
`- 更新 manifest.yaml 的 updated 日期`,
`- 无法独立解决:标记 🔴 blocked + blocked_reason跳过继续下一张`,
``,
`中途不需要等我确认,直接执行到底,全部完成后输出执行报告。`,
].join("\n");
navigator.clipboard.writeText(prompt);
setBatchCopied(true);
setTimeout(() => setBatchCopied(false), 2000);
};
// 收集所有可派发和进行中的任务
const actionItems = useMemo(() => {
const items: { module: BlueprintModule; task: BlueprintTask; dispatchable: boolean }[] = [];
for (const mod of modules) {
for (const t of mod.tasks) {
const dispatchable =
t.status === "todo" &&
!!t.files &&
!!t.acceptance &&
(t.complexity === "S" || t.complexity === "M");
if (dispatchable || t.status === "in_progress" || t.status === "blocked" || t.locked) {
items.push({ module: mod, task: t, dispatchable });
}
}
}
// blocked 最前进行中次之可派发再次locked 排最后
const priority = (item: typeof items[0]): number => {
if (item.task.status === "blocked") return 0;
if (item.task.status === "in_progress") return 1;
if (item.task.locked) return 3;
return 2; // dispatchable
};
items.sort((a, b) => priority(a) - priority(b));
return items;
}, [modules]);
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"></h3>
<div className="flex items-center gap-2">
<button
onClick={handleCopyBatchPrompt}
className="text-[11px] px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
title="复制批量执行提示词,粘贴给 Claude 自动完成所有 📋 任务卡"
>
{batchCopied ? "已复制 ✓" : "🚀 批量执行"}
</button>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
</div>
{actionItems.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-6"></p>
) : (
<div className="space-y-3">
{actionItems.filter((i) => i.task.status === "blocked").length > 0 && (
<div>
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider mb-1.5"> </p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.status === "blocked")
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-red-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
{actionItems.filter((i) => i.task.status === "in_progress").length > 0 && (
<div>
<p className="text-[10px] font-semibold text-blue-500 uppercase tracking-wider mb-1.5"></p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.status === "in_progress")
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-blue-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
{actionItems.filter((i) => i.dispatchable).length > 0 && (
<div>
<p className="text-[10px] font-semibold text-amber-500 uppercase tracking-wider mb-1.5"></p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.dispatchable)
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-blue-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
{actionItems.filter((i) => i.task.locked).length > 0 && (
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5"></p>
<div className="space-y-1.5">
{actionItems
.filter((i) => i.task.locked)
.map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-gray-600 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ── 蓝图治理面板 ────────────────────────────────────────────────────────────
function GovernancePanel({
projectPath,
projectName,
onClose,
onOpenFlywheel,
}: {
projectPath: string;
projectName: string;
onClose: () => void;
onOpenFlywheel?: () => void;
}) {
const { data: bpStatus, isPending: statusLoading, refetch: refetchStatus } = useQuery<BlueprintStatus>({
queryKey: ["bp-status-governance", projectPath],
queryFn: () => getBlueprintStatus(projectPath),
});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
const [prompt, setPrompt] = useState("");
const [promptLoading, setPromptLoading] = useState(false);
const [promptError, setPromptError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleSyncRules = async () => {
setSyncing(true);
setSyncError(null);
setSyncResult(null);
try {
const res = await syncBlueprintRules(projectPath);
setSyncResult(res);
refetchStatus();
} catch (e) {
setSyncError(String(e));
} finally {
setSyncing(false);
}
};
const handleOpenPrompt = async (mode: "init" | "sync") => {
if (promptMode === mode) {
setPromptMode(null);
return;
}
setPromptMode(mode);
setPrompt("");
setPromptError(null);
setPromptLoading(true);
try {
const result = await generateBlueprintPrompt(projectPath, mode);
setPrompt(result);
} catch (e) {
setPromptError(String(e));
} finally {
setPromptLoading(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const rulesOutdated = bpStatus?.status === "rules_outdated";
const currentVersion = bpStatus?.conventions_version ?? "—";
const masterVersion = bpStatus?.master_version ?? "—";
// 用后端完整状态判断rules_outdated 才是真正未同步content_stale/synced 都意味着规则已同步)
const versionSynced = bpStatus?.status === "synced" || bpStatus?.status === "content_stale";
return (
<div className="p-4 space-y-4">
{/* 标题 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"></h3>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</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">CONVENTIONS </p>
{statusLoading ? (
<p className="text-xs text-gray-400"></p>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-mono text-gray-700">{currentVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
<span className="text-xs font-mono text-gray-700">{masterVersion}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"></span>
{versionSynced ? (
<span className="text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded">🟢 </span>
) : (
<span className="text-xs text-yellow-600 bg-yellow-50 px-1.5 py-0.5 rounded">🟡 </span>
)}
</div>
</div>
)}
</div>
{/* 同步规则区域 */}
{rulesOutdated && !syncResult && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-800 font-semibold"></p>
<p className="text-[11px] text-yellow-700 leading-relaxed">
<code className="font-mono bg-yellow-100 px-1 rounded">.blueprint/CONVENTIONS.md</code> CLAUDE.md
</p>
{syncError && (
<p className="text-[11px] text-red-600 font-mono break-all">{syncError}</p>
)}
<button
onClick={handleSyncRules}
disabled={syncing}
className="w-full py-1.5 rounded-lg bg-yellow-500 text-white text-xs hover:bg-yellow-600 disabled:opacity-50 transition-colors"
>
{syncing ? "同步中…" : "同步规则"}
</button>
</div>
)}
{syncResult && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-xs text-green-700 font-semibold mb-1"> </p>
<p className="text-[11px] text-green-600 font-mono">{syncResult.message}</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>
<div className="flex gap-2">
<button
onClick={() => handleOpenPrompt("init")}
disabled={bpStatus?.status !== "none"}
title={bpStatus?.status !== "none" ? "该项目已有蓝图,请使用「更新蓝图描绘」" : undefined}
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
bpStatus?.status !== "none"
? "border border-gray-100 text-gray-300 cursor-not-allowed"
: promptMode === "init"
? "bg-blue-600 text-white"
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => handleOpenPrompt("sync")}
className={`flex-1 py-1.5 rounded-lg text-xs transition-colors ${
promptMode === "sync"
? "bg-blue-600 text-white"
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
</button>
</div>
</div>
{/* 提示词内容区 */}
{promptMode && (
<div className="space-y-2">
{promptLoading ? (
<p className="text-xs text-gray-400 text-center py-4"></p>
) : promptError ? (
<p className="text-xs text-red-500 font-mono break-all">{promptError}</p>
) : (
<>
<textarea
readOnly
value={prompt}
rows={12}
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 focus:ring-2 focus:ring-blue-200"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopy}
className="w-full py-1.5 rounded-lg bg-blue-600 text-white text-xs hover:bg-blue-700 transition-colors"
>
{copied ? "已复制 ✓" : "复制提示词"}
</button>
<p className="text-[10px] text-gray-400 text-center"></p>
</>
)}
</div>
)}
{/* 梦核分析入口 */}
{onOpenFlywheel && (
<div className="bg-emerald-50 rounded-lg border border-emerald-100 p-3 space-y-2">
<p className="text-[10px] font-semibold text-emerald-600 uppercase tracking-wider"> · </p>
<p className="text-[11px] text-emerald-700 leading-relaxed">
Claude Opus /
</p>
<button
onClick={onOpenFlywheel}
className="w-full py-1.5 rounded-lg bg-emerald-600 text-white text-xs hover:bg-emerald-700 transition-colors"
>
</button>
</div>
)}
{/* 项目信息 */}
<div className="pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-300 truncate">{projectName}</p>
</div>
</div>
);
}
// ── 未完成任务面板 ──────────────────────────────────────────────────────────
const REMAINING_ORDER: Record<string, number> = { blocked: 0, in_progress: 1, todo: 2, concept: 3 };
const REMAINING_LABEL: Record<string, string> = { blocked: "受阻", in_progress: "进行中", todo: "待开发", concept: "构思中" };
const REMAINING_COLOR: Record<string, string> = { blocked: "text-red-500", in_progress: "text-blue-500", todo: "text-amber-500", concept: "text-gray-400" };
function RemainingTasksPanel({
modules,
onSelectModule,
onClose,
}: {
modules: BlueprintModule[];
onSelectModule: (mod: BlueprintModule) => void;
onClose: () => void;
}) {
const grouped = useMemo(() => {
const groups: Record<string, { module: BlueprintModule; task: BlueprintTask }[]> = {};
for (const mod of modules) {
for (const t of mod.tasks) {
if (t.status === "done") continue;
const key = REMAINING_ORDER[t.status] !== undefined ? t.status : "concept";
if (!groups[key]) groups[key] = [];
groups[key].push({ module: mod, task: t });
}
}
return groups;
}, [modules]);
const sortedKeys = Object.keys(grouped).sort((a, b) => (REMAINING_ORDER[a] ?? 9) - (REMAINING_ORDER[b] ?? 9));
const totalRemaining = sortedKeys.reduce((sum, k) => sum + grouped[k].length, 0);
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800"></h3>
<p className="text-[10px] text-gray-400 mt-0.5"> {totalRemaining} </p>
</div>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</div>
{totalRemaining === 0 ? (
<p className="text-xs text-gray-400 text-center py-6"></p>
) : (
<div className="space-y-3">
{sortedKeys.map((status) => (
<div key={status}>
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1.5 ${REMAINING_COLOR[status] ?? "text-gray-400"}`}>
{REMAINING_LABEL[status] ?? status} ({grouped[status].length})
</p>
<div className="space-y-1.5">
{grouped[status].map((item, i) => (
<div key={i}>
<button
onClick={() => onSelectModule(item.module)}
className="text-[10px] text-gray-400 hover:text-blue-500 mb-0.5 transition-colors"
>
{item.module.name}
</button>
<TaskCard task={item.task} moduleName={item.module.name} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── 飞轮健康面板 ────────────────────────────────────────────────────────────
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 FlywheelPanel({ onClose }: { onClose: () => void }) {
const { data: projects } = useQuery<Project[]>({
queryKey: ["projects-for-flywheel"],
queryFn: () => getProjects(),
});
const { projectPaths, projectsMap } = useMemo(() => {
const paths = (projects ?? [])
.map((p) => p.win_path || p.wsl_path || "")
.filter(Boolean);
const map: Record<string, string> = {};
for (const p of (projects ?? [])) {
const path = p.win_path || p.wsl_path || "";
if (!path) continue;
// 用炼境项目名做 key
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 };
}, [projects]);
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 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)}%`;
// Fix 2去重——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 className="flex items-center justify-between">
<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>
<button onClick={onClose} className="text-gray-300 hover:text-gray-500 text-lg">×</button>
</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>
{/* Fix 4标签改为"任务完成率",字段改为 completion_rate */}
<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>
)}
{/* Fix 2仅展示"停转但未到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>
{/* Fix 1归档确认弹窗 */}
{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>
)}
{/* 梦核分析 */}
<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>
{/* Fix 3说清楚实际流程——梦核不自动写入需手动更新源码再发版 */}
<p className="text-[10px] text-gray-400 text-center leading-relaxed">
Claude Opus CONVENTIONS.md
</p>
</div>
)}
</div>
</>
)}
</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>
);
}