dev-manager-tauri/src/components/dashboard/BlueprintModal.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

1456 lines
60 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, useEffect, useRef } from "react";
import { AgentInfraPanel } from "./AgentInfraPanel";
import { GiteaPanel } from "./GiteaPanel";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event";
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,
applyOnboardingPack,
getCommitStats,
removeBlueprintBuff,
getBuffStatus,
type BlueprintData,
type BlueprintModule,
type BlueprintTask,
type BlueprintArea,
type BlueprintStatus,
type SyncResult,
type BuffStatus,
type TemplateResult,
type CommitStats,
} from "../../lib/commands";
interface Props {
projectName: string;
projectPath: string;
projectId?: 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: "受阻" },
abandoned: { color: "#52525B", bg: "#FAFAFA", 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" } | null;
export function BlueprintModal({ projectName, projectPath, projectId, onClose }: Props) {
const [sidePanel, setSidePanel] = useState<SidePanel>(null);
const [hoveredModuleId, setHoveredModuleId] = useState<string | null>(null);
const [pinnedModuleId, setPinnedModuleId] = useState<string | null>(null);
const [recentlyUpdated, setRecentlyUpdated] = useState(false);
const recentlyUpdatedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 实际高亮的模块pin 优先,否则用 hover
const activeModuleId = pinnedModuleId ?? hoveredModuleId;
const queryClient = useQueryClient();
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,
});
// 监听 blueprint://changed 事件,路径匹配时刷新数据并高亮提示
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ project_path: string }>("blueprint://changed", (event) => {
const norm = (s: string) => s.replace(/\\/g, "/").replace(/\/$/, "");
if (norm(event.payload.project_path) !== norm(projectPath)) return;
queryClient.invalidateQueries({ queryKey: ["blueprint", projectPath] });
setRecentlyUpdated(true);
if (recentlyUpdatedTimer.current) clearTimeout(recentlyUpdatedTimer.current);
recentlyUpdatedTimer.current = setTimeout(() => setRecentlyUpdated(false), 3000);
}).then((fn) => { unlisten = fn; });
return () => {
unlisten?.();
if (recentlyUpdatedTimer.current) clearTimeout(recentlyUpdatedTimer.current);
};
}, [projectPath, queryClient]);
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>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-800"></h2>
{recentlyUpdated && (
<span className="text-[10px] text-purple-600 bg-purple-50 border border-purple-200 px-1.5 py-0.5 rounded-full animate-pulse">
</span>
)}
</div>
<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 === "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>
)}
{/* 总进度条(固定在右侧) */}
<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}
projectId={projectId}
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,
projectId,
onClose,
}: {
projectPath: string;
projectName: string;
projectId?: string;
onClose: () => void;
}) {
const { data: bpStatus, isPending: statusLoading, refetch: refetchStatus } = useQuery<BlueprintStatus>({
queryKey: ["bp-status-governance", projectPath],
queryFn: () => getBlueprintStatus(projectPath),
});
const { data: buffStatus, refetch: refetchBuff } = useQuery<BuffStatus | null>({
queryKey: ["buff-status-governance", projectPath],
queryFn: () => getBuffStatus(projectPath),
staleTime: 5000,
});
const { data: commitStats } = useQuery<CommitStats | null>({
queryKey: ["commit-stats-governance", projectId],
queryFn: () => (projectId ? getCommitStats(projectId) : Promise.resolve(null)),
enabled: !!projectId,
staleTime: 5000,
});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const [buffLoading, setBuffLoading] = useState(false);
const [buffError, setBuffError] = useState<string | null>(null);
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
const [buffInitCopied, setBuffInitCopied] = useState(false);
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | 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 handleApplyBuff = async () => {
if (!projectId) return;
setBuffLoading(true);
setBuffError(null);
setBuffInitPrompt(null);
setPackTemplates(null);
try {
const report = await applyOnboardingPack(projectPath, projectId);
setPackTemplates(report.templates);
refetchBuff();
// 只有尚无蓝图时才展示 init prompt已有蓝图则 Buff 直接激活无需初始化
if (bpStatus?.status === "none") {
const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null);
if (p) setBuffInitPrompt(p);
}
} catch (e) {
setBuffError(String(e));
} finally {
setBuffLoading(false);
}
};
const handleRemoveBuff = async () => {
setBuffLoading(true);
setBuffError(null);
try {
await removeBlueprintBuff(projectPath);
refetchBuff();
setBuffInitPrompt(null);
setPackTemplates(null);
} catch (e) {
setBuffError(String(e));
} finally {
setBuffLoading(false);
}
};
const handleCopyBuffPrompt = () => {
if (!buffInitPrompt) return;
navigator.clipboard.writeText(buffInitPrompt).then(() => {
setBuffInitCopied(true);
setTimeout(() => setBuffInitCopied(false), 2000);
});
};
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>
)}
{/* 梦核分析入口 */}
{/* ── 蓝图 Buff ─────────────────────────────────────── */}
<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"></p>
{buffStatus?.status === "active" && (
<span className="text-[10px] text-purple-600 bg-purple-50 border border-purple-200 px-1.5 py-0.5 rounded-full"> </span>
)}
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">
AGENTS.md / lefthook / git-workflow buff git
</p>
{buffStatus?.last_sync_at && (
<p className="text-[10px] text-gray-400">{buffStatus.last_sync_at}</p>
)}
{buffError && (
<p className="text-[11px] text-red-600 font-mono break-all">{buffError}</p>
)}
{packTemplates && (
<div className="space-y-0.5 pt-0.5">
<p className="text-[10px] text-gray-500 font-semibold"></p>
{packTemplates.map((t) => (
<p key={t.name} className="text-[10px] font-mono flex items-center gap-1">
<span
className={
t.action === "written"
? "text-green-600"
: t.action === "skipped"
? "text-gray-400"
: "text-red-600"
}
>
{t.action === "written" ? "✓" : t.action === "skipped" ? "○" : "✗"}
</span>
<span className="text-gray-600 truncate flex-1">{t.name}</span>
<span className="text-gray-400">
{t.action === "written" ? "已写入" : t.action === "skipped" ? "已存在" : t.detail}
</span>
</p>
))}
</div>
)}
{buffStatus?.status === "active" ? (
<button
onClick={handleRemoveBuff}
disabled={buffLoading}
className="w-full py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{buffLoading ? "处理中…" : "撤销 Buff"}
</button>
) : (
<button
onClick={handleApplyBuff}
disabled={buffLoading || !projectId}
title={!projectId ? "需要传入 projectId" : undefined}
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{buffLoading ? "施加中…" : "⚡ 施加 Buff"}
</button>
)}
{/* 已有蓝图时施加 Buff直接激活无需初始化 */}
{buffStatus?.status === "active" && bpStatus?.status !== "none" && !buffInitPrompt && (
<p className="text-[10px] text-purple-500 leading-relaxed">
5 git 🔵
</p>
)}
{/* 无蓝图时施加 Buff展示 init prompt 供初始化 */}
{buffInitPrompt && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] text-purple-600 font-semibold"> Claude </p>
<textarea
readOnly
value={buffInitPrompt}
rows={8}
className="w-full font-mono text-[11px] text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-2 resize-none outline-none"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={handleCopyBuffPrompt}
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 transition-colors"
>
{buffInitCopied ? "已复制 ✓" : "复制提示词"}
</button>
</div>
)}
{buffStatus?.status === "active" && commitStats && commitStats.total > 0 && (
<div className="pt-1.5 border-t border-gray-100 space-y-0.5">
<p className="text-[10px] text-gray-500">
commit {commitStats.conventional}/{commitStats.total}
{Math.round((commitStats.conventional / commitStats.total) * 100)}% · {commitStats.rework}
</p>
{commitStats.conventional / commitStats.total < 0.6 && (
<p className="text-[10px] text-amber-600 leading-relaxed">
lefthook
</p>
)}
</div>
)}
</div>
{/* ── Agent 基础设施(接入包 v2───────────────────────── */}
<AgentInfraPanel projectPath={projectPath} projectId={projectId} />
{/* ── Gitea 集成(统一 git 后端)─────────────────────────── */}
{projectId && <GiteaPanel projectId={projectId} projectName={projectName} />}
{/* 项目信息 */}
<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>
);
}