dev-manager-tauri/src/components/manage/TemplateEditor.tsx
lanrtop 533c069e43 feat: add WSL editor launch modes and update checking functionality
- 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.
2026-04-01 23:12:23 +09:00

272 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 } 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>
);
}