dev-manager-tauri/src/components/manage/NewProjectModal.tsx
lanrtop 8c06aa5e44 feat: 飞轮智能阶段二完成——MCP SSE 传输 + 复盘覆盖率 + usage.json 扩展采集
- MCP Server 新增 GET/SSE 端点,Claude Code 原生 MCP 打通
- 项目导师面板显示复盘笔记覆盖率统计与缺口提醒
- usage.json 快照新增 complexity_dist、rework_count 等扩展字段
- 独立项目自动创建产品组并注入 MCP
- 新增 retro-catchup 技能用于批量补写历史复盘笔记
- 补全飞轮存量函数单元测试
- NewProjectModal 支持更灵活的模板参数配置
2026-04-13 01:49:56 +09:00

490 lines
22 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, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event";
import {
listTemplates, createProjectFromTemplate, TemplateVariant,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
spaceId?: string;
onClose: () => void;
}
type Step = "config" | "template" | "executing" | "done";
const PLATFORM_LABEL: Record<string, string> = {
both: "通用",
win: "仅 Win",
wsl: "仅 WSL",
};
const EXEC_STEPS = [
{ id: 1, label: "创建文件结构", icon: "📁" },
{ id: 2, label: "安装依赖", icon: "📦" },
{ id: 3, label: "注册项目", icon: "🗃️" },
];
export function NewProjectModal({ spaceId, onClose }: Props) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
// ── Step 1 ────────────────────────────────────────────────────────────────
const [platform, setPlatform] = useState<"win" | "wsl">("win");
const [parentPath, setParentPath] = useState("");
const [projectName, setProjectName] = useState("");
// ── Step 2 ────────────────────────────────────────────────────────────────
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [selectedVariantIdx, setSelectedVariantIdx] = useState<number>(0);
// ── Step 3/4 ──────────────────────────────────────────────────────────────
const [logs, setLogs] = useState<string[]>([]);
const [step, setStep] = useState<Step>("config");
const [execStep, setExecStep] = useState(0); // 1 / 2 / 3
const [currentCmd, setCurrentCmd] = useState(""); // 当前执行的命令
const [elapsed, setElapsed] = useState(0); // 秒
const [hasError, setHasError] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 日志自动滚到底
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
// 计时器:执行时启动,完成时停止
useEffect(() => {
if (step === "executing") {
setElapsed(0);
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [step]);
const { data: templates = [] } = useQuery({
queryKey: ["templates"],
queryFn: listTemplates,
});
const filteredTemplates = templates.filter(
(t) => t.platform === "both" || t.platform === platform
);
const selectedTemplate = filteredTemplates.find((t) => t.id === selectedTemplateId) ?? null;
const selectedVariants: TemplateVariant[] = (() => {
try { return selectedTemplate?.variants ? JSON.parse(selectedTemplate.variants) : []; }
catch { return []; }
})();
const projectFullPath = parentPath && projectName
? `${parentPath.replace(/[/\\]+$/, "")}\\${projectName}`
: "";
// ── 选择文件夹 ─────────────────────────────────────────────────────────────
const pickFolder = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") setParentPath(selected);
} catch (e) {
showToast(`❌ 选择文件夹失败: ${e}`);
}
};
// ── 执行创建 ──────────────────────────────────────────────────────────────
const handleCreate = async () => {
if (!selectedTemplateId || !parentPath || !projectName.trim()) return;
setStep("executing");
setLogs([]);
setExecStep(0);
setCurrentCmd("");
setHasError(false);
// 监听后端流式事件
const unlisten = await listen<string>("template:progress", (event) => {
const msg = event.payload;
if (msg.startsWith("__step:")) {
setExecStep(parseInt(msg.slice(7)));
} else if (msg.startsWith("__cmd:")) {
setCurrentCmd(msg.slice(6));
} else {
setLogs((prev) => [...prev, msg]);
if (msg.startsWith("❌") || msg.includes("✗")) setHasError(true);
}
});
try {
const variantCmd = selectedVariants.length > 0
? selectedVariants[selectedVariantIdx]?.initCommands
: undefined;
await createProjectFromTemplate(
selectedTemplateId,
parentPath,
projectName.trim(),
platform,
spaceId,
variantCmd,
);
qc.invalidateQueries({ queryKey: ["projects"] });
setStep("done");
} catch (e) {
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
setHasError(true);
setStep("done");
} finally {
unlisten();
}
};
const fmtTime = (s: number) =>
s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
// ── 渲染 ──────────────────────────────────────────────────────────────────
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-[600px] max-h-[85vh] flex flex-col border border-gray-200">
{/* 标题栏 */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<div>
<h2 className="text-base font-semibold text-gray-800"></h2>
<div className="flex items-center gap-3 mt-1">
{(["config", "template", "executing", "done"] as Step[]).map((s, i) => (
<div key={s} className="flex items-center gap-1.5">
{i > 0 && <span className="text-gray-200 text-xs"></span>}
<span className={`text-xs ${step === s ? "text-blue-600 font-semibold" : "text-gray-300"}`}>
{s === "config" ? "基本配置" : s === "template" ? "选择模板" : s === "executing" ? "执行中" : "完成"}
</span>
</div>
))}
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-5 space-y-4">
{/* ── Step 1基本配置 ── */}
{step === "config" && (
<>
{/* 平台 */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-2"></label>
<div className="flex gap-2">
{(["win", "wsl"] as const).map((p) => (
<button
key={p}
onClick={() => setPlatform(p)}
className={`flex-1 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
platform === p
? p === "win"
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-green-500 bg-green-50 text-green-700"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
{p === "win" ? "🪟 Windows" : "🐧 WSL / Linux"}
</button>
))}
</div>
</div>
{/* 父目录 */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-2"></label>
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
value={parentPath}
onChange={(e) => setParentPath(e.target.value)}
placeholder={platform === "wsl"
? "\\\\wsl.localhost\\Ubuntu-22.04\\home\\user\\projects"
: "C:\\Users\\user\\projects"}
/>
<button
onClick={pickFolder}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 shrink-0"
>
</button>
</div>
{platform === "wsl" && (
<p className="text-xs text-gray-400 mt-1">
WSL <code className="bg-gray-100 px-1 rounded">\\wsl.localhost\\...</code>
</p>
)}
</div>
{/* 项目名称 */}
<div>
<label className="block text-xs font-semibold text-gray-600 mb-2"></label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-awesome-project"
/>
</div>
{/* 路径预览 */}
{projectFullPath && (
<div className="bg-gray-50 rounded-lg px-4 py-3 text-xs">
<span className="text-gray-400"></span>
<span className="font-mono text-gray-700 ml-1 break-all">{projectFullPath}</span>
</div>
)}
</>
)}
{/* ── Step 2选择模板 ── */}
{step === "template" && (
<div className="space-y-2">
<p className="text-xs text-gray-400"></p>
{filteredTemplates.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8"></p>
)}
{filteredTemplates.map((t) => {
const isSelected = selectedTemplateId === t.id;
const variants: TemplateVariant[] = (() => {
try { return t.variants ? JSON.parse(t.variants) : []; }
catch { return []; }
})();
return (
<div key={t.id} className="space-y-2">
<button
onClick={() => { setSelectedTemplateId(t.id); setSelectedVariantIdx(0); }}
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${
isSelected
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-blue-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<span className="font-semibold text-sm text-gray-800">{t.name}</span>
{t.isBuiltin && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200"></span>
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded border ml-auto ${
t.platform === "wsl" ? "bg-green-50 text-green-600 border-green-200" :
t.platform === "win" ? "bg-blue-50 text-blue-600 border-blue-200" :
"bg-gray-50 text-gray-500 border-gray-200"
}`}>
{PLATFORM_LABEL[t.platform] ?? t.platform}
</span>
</div>
{t.description && (
<p className="text-xs text-gray-400 mt-0.5">{t.description}</p>
)}
{t.techStack && (
<div className="flex flex-wrap gap-1 mt-1.5">
{t.techStack.split(",").map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-indigo-50 text-indigo-500">
{tag.trim()}
</span>
))}
</div>
)}
{variants.length === 0 && t.initCommands && (
<p className="text-[11px] text-gray-300 mt-1 font-mono">$ {t.initCommands.split("\n")[0]}</p>
)}
</button>
{/* variant 下拉:仅在选中且有 variants 时展开 */}
{isSelected && variants.length > 0 && (
<div className="ml-3 border-l-2 border-blue-200 pl-3 space-y-1">
<p className="text-[10px] text-gray-400 font-medium mb-1.5"></p>
{variants.map((v, idx) => (
<button
key={idx}
onClick={(e) => { e.stopPropagation(); setSelectedVariantIdx(idx); }}
className={`w-full text-left px-3 py-2 rounded-lg border transition-all ${
selectedVariantIdx === idx
? "border-blue-400 bg-blue-50"
: "border-gray-100 hover:border-blue-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-1.5">
<span className={`w-3.5 h-3.5 rounded-full border flex-shrink-0 ${
selectedVariantIdx === idx
? "border-blue-500 bg-blue-500"
: "border-gray-300"
}`} />
<span className="text-xs font-medium text-gray-700">{v.label}</span>
</div>
{v.description && (
<p className="text-[11px] text-gray-400 mt-0.5 ml-5">{v.description}</p>
)}
<p className="text-[10px] text-gray-300 mt-0.5 ml-5 font-mono">$ {v.initCommands}</p>
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Step 3/4执行进度 ── */}
{(step === "executing" || step === "done") && (
<div className="space-y-4">
{/* 步骤指示器 */}
<div className="space-y-2">
{EXEC_STEPS.map((s) => {
const isDone = step === "done" ? execStep >= s.id : execStep > s.id;
const isActive = step === "executing" && execStep === s.id;
return (
<div key={s.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isActive ? "bg-blue-50 border border-blue-100" :
isDone ? "bg-green-50 border border-green-100" :
"bg-gray-50 border border-gray-100"
}`}>
{/* 图标 */}
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs shrink-0 ${
isDone ? "bg-green-500 text-white" :
isActive ? "bg-blue-500 text-white" :
"bg-gray-200 text-gray-400"
}`}>
{isDone ? "✓" : isActive ? (
<span className="animate-spin inline-block"></span>
) : s.id}
</div>
{/* 标签 */}
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium ${
isActive ? "text-blue-700" : isDone ? "text-green-700" : "text-gray-400"
}`}>
{s.icon} {s.label}
{isActive && s.id === 2 && currentCmd && (
<span className="ml-2 font-mono text-blue-500 font-normal">{currentCmd}</span>
)}
</div>
{/* 进度条(仅当前步骤显示)*/}
{isActive && (
<div className="mt-1.5 h-1 bg-blue-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400 rounded-full"
style={{
width: "40%",
animation: "indeterminate-progress 1.4s ease-in-out infinite",
}}
/>
</div>
)}
</div>
{/* 计时 */}
{isActive && (
<span className="text-xs text-blue-400 shrink-0 tabular-nums">{fmtTime(elapsed)}</span>
)}
{isDone && s.id === execStep - 1 && step === "executing" && (
<span className="text-xs text-green-500 shrink-0"></span>
)}
</div>
);
})}
</div>
{/* 实时日志(可滚动,最近 200 行)*/}
<div className="bg-gray-900 rounded-lg overflow-hidden">
<div className="px-3 py-1.5 border-b border-gray-700 flex items-center gap-2">
<span className="text-[10px] text-gray-400 font-mono"></span>
{step === "executing" && (
<span className="text-[10px] text-blue-400 animate-pulse"> </span>
)}
</div>
<div className="h-40 overflow-y-auto px-3 py-2 space-y-0.5">
{logs.length === 0 && (
<div className="text-[11px] text-gray-500 font-mono animate-pulse"></div>
)}
{logs.slice(-200).map((line, i) => (
<div key={i} className={`text-[11px] font-mono leading-relaxed ${
line.startsWith("❌") || line.includes("✗") ? "text-red-400" :
line.startsWith("✅") ? "text-green-400 font-semibold" :
line.startsWith("⚙") ? "text-blue-400" :
line.startsWith("⚠") ? "text-yellow-400" :
line.startsWith("📁") || line.startsWith("📄") ? "text-gray-300" :
"text-gray-500"
}`}>
{line}
</div>
))}
<div ref={logEndRef} />
</div>
</div>
{/* 完成提示 */}
{step === "done" && !hasError && (
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
🎉 {fmtTime(elapsed)}
</div>
)}
{step === "done" && hasError && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700 text-center">
</div>
)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex justify-between items-center px-6 py-3 border-t border-gray-100 shrink-0">
<div>
{step === "template" && (
<button
onClick={() => setStep("config")}
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
>
</button>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={step === "executing"}
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
{step === "done" ? "关闭" : "取消"}
</button>
{step === "config" && (
<button
onClick={() => setStep("template")}
disabled={!parentPath.trim() || !projectName.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
</button>
)}
{step === "template" && (
<button
onClick={handleCreate}
disabled={!selectedTemplateId}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
</button>
)}
</div>
</div>
</div>
</div>
);
}