上一会话遗留的完整功能补提交: - 新增 4 个三端体系内置模板:React Web / React + Capacitor / React + Tauri (Antd) / React Tri-Platform(同一架构按需起步) - tech_stack 升级为分类 JSON 格式(客户端/语言/框架), 新增 techStackUtils.renderTechStack 兼容旧逗号格式, ManagePage / NewProjectModal 接线,TemplateEditor 占位符同步 Feature-Confirmed: true
483 lines
21 KiB
TypeScript
483 lines
21 KiB
TypeScript
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";
|
||
import { renderTechStack } from "./techStackUtils";
|
||
|
||
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="mt-1.5">{renderTechStack(t.techStack)}</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>
|
||
);
|
||
}
|