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 = { 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 = { done: "✅", in_progress: "🔵", todo: "📋", concept: "💭", blocked: "🔴", locked: "🔒", }; // ── 领域分组节点 ───────────────────────────────────────────────────────────── function AreaNode({ data }: { data: { label: string; color: string } }) { return (
{data.label}
); } // ── 模块节点 ───────────────────────────────────────────────────────────────── 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 (
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", }} >
{data.name}
{/* 进度条 */}
{cfg.label} {tasksTotal > 0 && ( {tasksDone}/{tasksTotal} )}
); } 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 = {}; 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(null); const [hoveredModuleId, setHoveredModuleId] = useState(null); const [pinnedModuleId, setPinnedModuleId] = useState(null); // 实际高亮的模块:pin 优先,否则用 hover const activeModuleId = pinnedModuleId ?? hoveredModuleId; const { data: blueprint, isLoading, error } = useQuery({ queryKey: ["blueprint", projectPath], queryFn: () => getBlueprint(projectPath), }); const { data: bpStatus } = useQuery({ 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([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 }; 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 单独 memo,hover 只重建边的样式 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 (
{/* 标题栏 */}

项目蓝图

{projectName} {blueprint?.manifest.updated && ( 更新于 {blueprint.manifest.updated} )}

{/* 统计条 */} {stats && (
{blueprint?.manifest.iteration != null && blueprint.manifest.iteration > 1 && ( 迭代 {blueprint.manifest.iteration} )} {stats.tasks_blocked > 0 && ( )}
任务 {stats.tasks_done}/{stats.total_tasks} {stats.total_tasks - stats.tasks_done > 0 && ( )} {stats.dispatchable > 0 && ( )} {/* 规则 & AI文档 状态指示器 */} {bpStatus && ( )} {/* 总进度条(固定在右侧) */}
{pinnedModuleId && ( )}
0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%` }} />
{stats.total_modules > 0 ? Math.round((stats.done / stats.total_modules) * 100) : 0}%
)} {/* 图例 */} {blueprint && (
图例:
依赖
关联
{blueprint.manifest.areas.map((a) => (
{a.name}
))}
)} {/* 主体区域 */}
{/* React Flow 画布 */}
{isLoading ? (
加载中...
) : error ? (
!

蓝图解析失败

{error instanceof Error ? error.message : String(error)}

请检查 .blueprint/manifest.yaml 格式是否正确

) : !blueprint ? (
🗺️

该项目尚未创建蓝图

在项目根目录创建 .blueprint/ 目录即可启用

) : ( )}
{/* 侧边面板 */} {sidePanel && (
{sidePanel.type === "module" ? ( { setSidePanel(null); setPinnedModuleId(null); }} /> ) : sidePanel.type === "remaining" ? ( { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }} onClose={() => { setSidePanel(null); setPinnedModuleId(null); }} /> ) : sidePanel.type === "governance" ? ( setSidePanel(null)} onOpenFlywheel={() => setSidePanel({ type: "flywheel" })} /> ) : sidePanel.type === "flywheel" ? ( setSidePanel(null)} /> ) : ( { setSidePanel({ type: "module", module: mod }); setPinnedModuleId(mod.id); }} onClose={() => { setSidePanel(null); setPinnedModuleId(null); }} /> )}
)}
); } // ── 统计小标签 ─────────────────────────────────────────────────────────────── function StatBadge({ color, label, count }: { color: string; label: string; count: number }) { return (
{label} {count}
); } // ── 模块详情面板 ───────────────────────────────────────────────────────────── 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 = { 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 = { done: "已完成", in_progress: "进行中", blocked: "受阻", todo: "待开发", concept: "构思中", }; return (
{/* 标题 */}

{mod.name}

{cfg.label} · {mod.progress}% {area && ( {area.name} )}
{/* 进度条 */}
{/* 描述 */} {mod.description && (

{mod.description}

)} {/* 决策记录 */} {mod.decisions && mod.decisions.length > 0 && (

决策记录

    {mod.decisions.map((d, i) => (
  • · {d}
  • ))}
)} {/* 任务卡列表 */} {mod.tasks.length > 0 && (
{(["blocked", "in_progress", "todo", "concept", "done"] as const).map((status) => { const tasks = grouped[status]; if (!tasks || tasks.length === 0) return null; return (

{groupLabels[status]} ({tasks.length})

{tasks.map((t, i) => ( ))}
); })}
)}
); } // ── 任务卡 ─────────────────────────────────────────────────────────────────── 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 (
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" }`} >
{prefix} {task.title} {task.complexity && ( {task.complexity} )} {isDispatchable && ( )}
{/* locked 依赖提示,始终显示 */} {task.locked && task.depends && (
待解锁,依赖: {task.depends}
)} {/* blocked_reason 始终显示,不需要展开 */} {task.status === "blocked" && task.blocked_reason && (
阻塞原因: {task.blocked_reason}
)} {expanded && (
{task.files && (

文件: {task.files}

)} {task.acceptance && (

验收: {task.acceptance}

)} {task.depends && (

依赖: {task.depends}

)} {task.notes && (

备注: {task.notes}

)}
)}
); } // ── 下一步汇总面板 ────────────────────────────────────────────────────────── 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 (

下一步

{actionItems.length === 0 ? (

暂无可执行任务

) : (
{actionItems.filter((i) => i.task.status === "blocked").length > 0 && (

受阻 — 需架构师介入

{actionItems .filter((i) => i.task.status === "blocked") .map((item, i) => (
))}
)} {actionItems.filter((i) => i.task.status === "in_progress").length > 0 && (

进行中

{actionItems .filter((i) => i.task.status === "in_progress") .map((item, i) => (
))}
)} {actionItems.filter((i) => i.dispatchable).length > 0 && (

可派发

{actionItems .filter((i) => i.dispatchable) .map((item, i) => (
))}
)} {actionItems.filter((i) => i.task.locked).length > 0 && (

待解锁(依赖未完成)

{actionItems .filter((i) => i.task.locked) .map((item, i) => (
))}
)}
)}
); } // ── 蓝图治理面板 ──────────────────────────────────────────────────────────── function GovernancePanel({ projectPath, projectName, onClose, onOpenFlywheel, }: { projectPath: string; projectName: string; onClose: () => void; onOpenFlywheel?: () => void; }) { const { data: bpStatus, isPending: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ["bp-status-governance", projectPath], queryFn: () => getBlueprintStatus(projectPath), }); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); const [syncError, setSyncError] = useState(null); const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [prompt, setPrompt] = useState(""); const [promptLoading, setPromptLoading] = useState(false); const [promptError, setPromptError] = useState(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 (
{/* 标题 */}

蓝图治理

{/* 规则版本状态 */}

CONVENTIONS 规则版本

{statusLoading ? (

检测中…

) : (
当前版本 {currentVersion}
最新版本 {masterVersion}
状态 {versionSynced ? ( 🟢 已同步 ) : ( 🟡 待更新 )}
)}
{/* 同步规则区域 */} {rulesOutdated && !syncResult && (

规则版本待更新

将覆盖写入 .blueprint/CONVENTIONS.md 并更新 CLAUDE.md 中的蓝图规则区块。

{syncError && (

{syncError}

)}
)} {syncResult && (

✓ 同步完成

{syncResult.message}

)} {/* 蓝图内容提示词 */}

蓝图内容

将提示词交给 Claude Opus,由它负责理解和创作蓝图内容。

{/* 提示词内容区 */} {promptMode && (
{promptLoading ? (

生成提示词中…

) : promptError ? (

{promptError}

) : ( <>