1622 lines
67 KiB
TypeScript
1622 lines
67 KiB
TypeScript
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 单独 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 (
|
||
<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;
|
||
// 同时用路径末尾目录名做备用 key(usage.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">
|
||
飞轮停转(60–90天无快照)
|
||
</p>
|
||
{stalledOnlyProjects.map((p, i) => (
|
||
<StalledProjectRow key={i} p={p} projectsMap={projectsMap} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* AI文档休眠项目(≥90天) */}
|
||
{stats.ai_doc_stale_projects.length > 0 && (
|
||
<div className="bg-red-50 rounded-lg border border-red-100 p-3 space-y-1.5">
|
||
<p className="text-[10px] font-semibold text-red-500 uppercase tracking-wider">
|
||
AI文档休眠(90天+无快照)
|
||
</p>
|
||
<p className="text-[10px] text-red-400 mb-1">
|
||
建议归档 .blueprint/modules/,清空 Claude 上下文负担。
|
||
</p>
|
||
{/* 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>
|
||
);
|
||
}
|