- Introduced WSL editor launch modes in SettingsPage with options for internal, remote, and UNC path launches. - Enhanced settings saving to include the new WSL launch mode. - Added an update section to check for application updates, download, and install them with progress tracking. - Updated Toast component to handle error messages with a copy button for convenience. - Expanded commands to include project tags and templates management, along with WSL proxy synchronization features.
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { getTemplate, saveTemplate, type ProjectTemplate } from "../../lib/commands";
|
||
import { useUIStore } from "../../store/ui";
|
||
import { Dialog } from "../ui/Dialog";
|
||
|
||
interface Props {
|
||
templateId: string | null; // null = 新建
|
||
onClose: () => void;
|
||
}
|
||
|
||
interface FileEntry {
|
||
_key: string;
|
||
path: string;
|
||
content: string;
|
||
}
|
||
|
||
const PLATFORM_OPTIONS = [
|
||
{ value: "both", label: "通用(Win + WSL)" },
|
||
{ value: "win", label: "仅 Windows" },
|
||
{ value: "wsl", label: "仅 WSL / Linux" },
|
||
];
|
||
|
||
export function TemplateEditor({ templateId, onClose }: Props) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const qc = useQueryClient();
|
||
|
||
const [loading, setLoading] = useState(!!templateId);
|
||
const [saving, setSaving] = useState(false);
|
||
const [isBuiltin, setIsBuiltin] = useState(false);
|
||
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [platform, setPlatform] = useState("both");
|
||
const [techStack, setTechStack] = useState("");
|
||
const [initCommands, setInitCommands] = useState("");
|
||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!templateId) { setLoading(false); return; }
|
||
getTemplate(templateId).then((t: ProjectTemplate) => {
|
||
setName(t.name);
|
||
setDescription(t.description ?? "");
|
||
setPlatform(t.platform);
|
||
setTechStack(t.techStack ?? "");
|
||
setInitCommands(t.initCommands ?? "");
|
||
setIsBuiltin(t.isBuiltin);
|
||
const entries = t.files.map((f) => ({
|
||
_key: crypto.randomUUID(),
|
||
path: f.path,
|
||
content: f.content,
|
||
}));
|
||
setFiles(entries);
|
||
if (entries.length > 0) setSelectedKey(entries[0]._key);
|
||
}).catch((e) => showToast(`❌ 加载失败: ${e}`))
|
||
.finally(() => setLoading(false));
|
||
}, [templateId]);
|
||
|
||
const addFile = () => {
|
||
const entry: FileEntry = { _key: crypto.randomUUID(), path: "", content: "" };
|
||
setFiles((prev) => [...prev, entry]);
|
||
setSelectedKey(entry._key);
|
||
};
|
||
|
||
const removeFile = (key: string) => {
|
||
setFiles((prev) => prev.filter((f) => f._key !== key));
|
||
setSelectedKey((prev) => {
|
||
if (prev !== key) return prev;
|
||
const remaining = files.filter((f) => f._key !== key);
|
||
return remaining[0]?._key ?? null;
|
||
});
|
||
};
|
||
|
||
const updateFile = (key: string, field: "path" | "content", value: string) => {
|
||
setFiles((prev) => prev.map((f) => f._key === key ? { ...f, [field]: value } : f));
|
||
};
|
||
|
||
const selectedFile = files.find((f) => f._key === selectedKey) ?? null;
|
||
|
||
const handleSave = async () => {
|
||
if (!name.trim()) { showToast("❌ 模板名称不能为空"); return; }
|
||
setSaving(true);
|
||
try {
|
||
await saveTemplate({
|
||
id: templateId ?? undefined,
|
||
name: name.trim(),
|
||
description: description.trim() || undefined,
|
||
platform,
|
||
techStack: techStack.trim() || undefined,
|
||
initCommands: initCommands.trim() || undefined,
|
||
files: files
|
||
.filter((f) => f.path.trim())
|
||
.map((f) => ({ path: f.path.trim(), content: f.content })),
|
||
});
|
||
qc.invalidateQueries({ queryKey: ["templates"] });
|
||
showToast(templateId ? "已更新" : "已创建");
|
||
onClose();
|
||
} catch (e) {
|
||
showToast(`❌ 保存失败: ${e}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
title={templateId ? (isBuiltin ? `查看内置模板:${name}` : `编辑模板:${name}`) : "新建模板"}
|
||
onClose={onClose}
|
||
fixedHeight="82vh"
|
||
>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-40 text-gray-400 text-sm">加载中…</div>
|
||
) : (
|
||
<div className="flex flex-col flex-1 min-h-0">
|
||
{/* ── 元信息表单 ── */}
|
||
<div className="shrink-0 px-6 py-4 border-b border-gray-100 grid grid-cols-2 gap-3">
|
||
<div className="col-span-2">
|
||
<label className="block text-xs font-medium text-gray-500 mb-1">模板名称 *</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={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder="如:pnpm Monorepo"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="block text-xs font-medium text-gray-500 mb-1">描述</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={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder="一句话说明用途"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-500 mb-1">适用平台</label>
|
||
<select
|
||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
|
||
value={platform}
|
||
onChange={(e) => setPlatform(e.target.value)}
|
||
disabled={isBuiltin}
|
||
>
|
||
{PLATFORM_OPTIONS.map((o) => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-500 mb-1">技术栈标签(逗号分隔)</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={techStack}
|
||
onChange={(e) => setTechStack(e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder="Node,pnpm,TypeScript"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||
初始化命令(每行一条,支持 # 注释)
|
||
</label>
|
||
<textarea
|
||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200 resize-none"
|
||
rows={3}
|
||
value={initCommands}
|
||
onChange={(e) => setInitCommands(e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder={"pnpm install\n# git init"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 文件编辑区 ── */}
|
||
<div className="flex flex-1 min-h-0">
|
||
{/* 左侧文件列表 */}
|
||
<div className="w-52 shrink-0 border-r border-gray-100 flex flex-col min-h-0">
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 shrink-0">
|
||
<span className="text-xs font-semibold text-gray-500">文件列表</span>
|
||
{!isBuiltin && (
|
||
<button
|
||
onClick={addFile}
|
||
className="text-blue-500 hover:text-blue-700 text-lg leading-none font-light"
|
||
title="添加文件"
|
||
>+</button>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
{files.length === 0 && (
|
||
<p className="text-xs text-gray-400 text-center py-6">暂无文件</p>
|
||
)}
|
||
{files.map((f) => (
|
||
<div
|
||
key={f._key}
|
||
onClick={() => setSelectedKey(f._key)}
|
||
className={`group flex items-center gap-1.5 px-3 py-2 cursor-pointer text-xs font-mono border-b border-gray-50 ${
|
||
selectedKey === f._key
|
||
? "bg-blue-50 text-blue-700"
|
||
: "text-gray-600 hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
<span className="flex-1 truncate">{f.path || <span className="text-gray-300 italic">未命名</span>}</span>
|
||
{!isBuiltin && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); removeFile(f._key); }}
|
||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 shrink-0"
|
||
>×</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧编辑区 */}
|
||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||
{selectedFile ? (
|
||
<>
|
||
<div className="shrink-0 px-3 py-2 border-b border-gray-100 flex items-center gap-2">
|
||
<span className="text-xs text-gray-400">路径:</span>
|
||
<input
|
||
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs font-mono outline-none focus:ring-1 focus:ring-blue-200"
|
||
value={selectedFile.path}
|
||
onChange={(e) => updateFile(selectedFile._key, "path", e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder="如:package.json 或 src/index.ts"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-h-0 relative">
|
||
<textarea
|
||
className="absolute inset-0 w-full h-full px-3 py-3 text-xs font-mono resize-none outline-none border-0 bg-gray-50"
|
||
value={selectedFile.content}
|
||
onChange={(e) => updateFile(selectedFile._key, "content", e.target.value)}
|
||
disabled={isBuiltin}
|
||
placeholder={"文件内容\n可使用变量:{{project_name}} {{project_name_kebab}} {{project_name_snake}}"}
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex-1 flex items-center justify-center text-gray-300 text-sm">
|
||
{isBuiltin ? "点击左侧文件查看内容" : "点击左侧文件或 + 新增文件"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 底部按钮 ── */}
|
||
<div className="shrink-0 flex justify-end gap-3 px-6 py-3 border-t border-gray-100">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
|
||
>
|
||
{isBuiltin ? "关闭" : "取消"}
|
||
</button>
|
||
{!isBuiltin && (
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{saving ? "保存中…" : "保存"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Dialog>
|
||
);
|
||
}
|