dev-manager-tauri/src/components/manage/NewProjectModal.tsx
lanrtop 3d9336d908 feat(templates): 三端体系内置模板 + 技术栈分类标签渲染
上一会话遗留的完整功能补提交:
- 新增 4 个三端体系内置模板:React Web / React + Capacitor /
  React + Tauri (Antd) / React Tri-Platform(同一架构按需起步)
- tech_stack 升级为分类 JSON 格式(客户端/语言/框架),
  新增 techStackUtils.renderTechStack 兼容旧逗号格式,
  ManagePage / NewProjectModal 接线,TemplateEditor 占位符同步

Feature-Confirmed: true
2026-07-18 10:48:27 +09:00

483 lines
21 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";
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>
);
}