All checks were successful
Push & PR Check / check (push) Successful in 1m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { open } from "@tauri-apps/plugin-dialog";
|
||
import { createProject, updateProject, getSpaces, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
|
||
import { useUIStore } from "../../store/ui";
|
||
import { Dialog } from "../ui/Dialog";
|
||
|
||
const EMPTY: Partial<Project> = {
|
||
id: "", name: "", description: "", type: "", status: "active", priority: "mid",
|
||
wsl_path: "", win_path: "", tech_stack: "", tags: "", recent_focus: "", startup_commands: "",
|
||
platform: "windows",
|
||
};
|
||
|
||
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400";
|
||
const labelCls = "block text-xs font-medium text-gray-600 mb-1";
|
||
const sectionCls = "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3";
|
||
|
||
export function ProjectModal() {
|
||
const editTarget = useUIStore((s) => s.editTarget);
|
||
const closeEdit = useUIStore((s) => s.closeEdit);
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||
const qc = useQueryClient();
|
||
|
||
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
|
||
const currentSpace = spaces.find((s) => s.is_current);
|
||
|
||
const [form, setForm] = useState<Partial<Project>>(EMPTY);
|
||
const [saving, setSaving] = useState(false);
|
||
const [tab, setTab] = useState<"info" | "deploy">("info");
|
||
const isNew = editTarget === null;
|
||
|
||
// 标签多选
|
||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||
const { data: allTags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags });
|
||
|
||
useEffect(() => {
|
||
setForm(editTarget ?? EMPTY);
|
||
if (editTarget && editTarget.id) {
|
||
getProjectTags(editTarget.id).then((tags) => {
|
||
setSelectedTagIds(tags.map((t) => t.id));
|
||
}).catch(() => {});
|
||
} else {
|
||
setSelectedTagIds([]);
|
||
}
|
||
}, [editTarget]);
|
||
|
||
if (editTarget === undefined) return null;
|
||
|
||
const set = (key: keyof Project, value: string) =>
|
||
setForm((f) => ({ ...f, [key]: value }));
|
||
|
||
const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||
const selected = await open({ directory: true, multiple: false });
|
||
if (!selected) return;
|
||
// 直接存原始路径(WSL 路径为 \\wsl.localhost\distro\... 格式,Windows 文件管理器可直接访问)
|
||
set(field, selected as string);
|
||
};
|
||
|
||
// 新建项目顶部快速选择:选文件夹后自动填充路径、名称、ID
|
||
// WSL 路径直接存 UNC 格式(\\wsl.localhost\Ubuntu-22.04\home\...),不再转换为 Linux 路径
|
||
const quickPickFolder = async () => {
|
||
const selected = await open({ directory: true, multiple: false });
|
||
if (!selected) return;
|
||
const raw = selected as string;
|
||
const isWsl = form.platform === "wsl";
|
||
// UNC 路径兼容提取文件夹名
|
||
const folderName = raw.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
||
setForm((f) => ({
|
||
...f,
|
||
[isWsl ? "wsl_path" : "win_path"]: raw,
|
||
name: folderName,
|
||
id: crypto.randomUUID(),
|
||
}));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!form.name?.trim()) { showToast("❌ 项目名称不能为空"); return; }
|
||
if (isNew && !form.id?.trim()) { showToast("❌ 项目 ID 不能为空"); return; }
|
||
setSaving(true);
|
||
try {
|
||
if (isNew) {
|
||
await createProject({ ...form, space_id: currentSpace?.id } as Project);
|
||
// 新建时用前端生成的 workspace id(即 form.id)设置标签
|
||
if (form.id && selectedTagIds.length > 0) {
|
||
await setProjectTags(form.id, selectedTagIds).catch(() => {});
|
||
}
|
||
showToast("项目已添加 ✓");
|
||
} else {
|
||
await updateProject(editTarget!.id, form);
|
||
await setProjectTags(editTarget!.id, selectedTagIds);
|
||
showToast("已保存 ✓");
|
||
}
|
||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||
qc.invalidateQueries({ queryKey: ["tagMaps"] });
|
||
closeEdit();
|
||
// create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入
|
||
// 与后端登记同口径:按 platform 只看有效根目录那一列(另一列残留不算)
|
||
const effectiveRoot = form.platform === "wsl" ? form.wsl_path : form.win_path;
|
||
if (isNew && effectiveRoot?.trim()) {
|
||
openOnboard({ projectId: form.id!, projectName: form.name?.trim() });
|
||
}
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog title={isNew ? "添加项目" : "编辑项目"} onClose={closeEdit} size="lg" fixedHeight="82vh">
|
||
|
||
{/* ── 运行平台 + 项目文件夹(新建/编辑均显示)── */}
|
||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 shrink-0">
|
||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||
{isNew ? "选择项目文件夹" : "运行平台 / 项目路径"}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
{/* 运行平台 */}
|
||
<div className="flex gap-1.5 shrink-0">
|
||
{(["windows", "wsl"] as const).map((p) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onClick={() => setForm((f) => ({ ...f, platform: p }))}
|
||
className={`px-3 py-1.5 rounded-md text-xs border transition-colors ${
|
||
form.platform === p
|
||
? "border-blue-400 bg-blue-50 text-blue-700 font-medium"
|
||
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
|
||
}`}
|
||
>
|
||
{p === "windows" ? "Windows" : "WSL / Linux"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* 文件夹选择 / 路径输入 */}
|
||
<button
|
||
type="button"
|
||
onClick={isNew ? quickPickFolder : () => pickFolder(form.platform === "wsl" ? "wsl_path" : "win_path")}
|
||
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-gray-300 text-sm text-gray-400 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50/40 transition-colors bg-white"
|
||
>
|
||
<span>📁</span>
|
||
<span className="truncate font-mono text-xs">
|
||
{(form.platform === "wsl" ? form.wsl_path : form.win_path) || "点击选择项目文件夹…"}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{isNew && (form.platform === "wsl" ? form.wsl_path : form.win_path) && (
|
||
<p className="text-xs text-green-600 mt-2">✓ 已自动填充项目名称与 ID,可在下方继续编辑</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── 标签页导航 ── */}
|
||
<div className="border-b border-gray-200 px-6 flex gap-1 shrink-0">
|
||
{([
|
||
{ id: "info" as const, label: "基本信息" },
|
||
{ id: "deploy" as const, label: "部署信息" },
|
||
]).map(({ id, label }) => (
|
||
<button
|
||
key={id}
|
||
onClick={() => setTab(id)}
|
||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||
tab === id
|
||
? "border-blue-500 text-blue-600"
|
||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Tab 内容(可滚动区)── */}
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
|
||
{/* ── Tab: 基本信息 ── */}
|
||
{tab === "info" && (
|
||
<div className="px-6 py-5 space-y-5">
|
||
|
||
{/* 项目标识 */}
|
||
<div>
|
||
<p className={sectionCls}>项目标识</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className={labelCls}>项目 ID *</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
className={`${inputCls} font-mono flex-1`}
|
||
value={form.id ?? ""}
|
||
placeholder="my-project"
|
||
onChange={(e) => set("id", e.target.value)}
|
||
disabled={!isNew}
|
||
/>
|
||
{isNew && (
|
||
<button
|
||
type="button"
|
||
onClick={() => set("id", crypto.randomUUID())}
|
||
className="px-3 py-2 rounded-lg border border-gray-200 text-xs text-gray-500 hover:bg-gray-50 shrink-0"
|
||
title="自动生成 ID"
|
||
>
|
||
自动生成
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>项目名称 *</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.name ?? ""}
|
||
placeholder="My Project"
|
||
onChange={(e) => set("name", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>描述</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.description ?? ""}
|
||
onChange={(e) => set("description", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分类与状态 */}
|
||
<div className="border-t border-gray-100 pt-4">
|
||
<p className={sectionCls}>分类与状态</p>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className={labelCls}>类型</label>
|
||
<select className={inputCls} value={form.type ?? ""} onChange={(e) => set("type", e.target.value)}>
|
||
<option value="">— 不指定 —</option>
|
||
{["frontend","backend","fullstack","mobile","tool","library","infra"].map((t) => (
|
||
<option key={t} value={t}>{t}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>状态</label>
|
||
<select className={inputCls} value={form.status ?? "active"} onChange={(e) => set("status", e.target.value)}>
|
||
{[["active","进行中"],["maintenance","维护中"],["paused","暂停"],["archived","已归档"]].map(([v, l]) => (
|
||
<option key={v} value={v}>{l}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>优先级</label>
|
||
<select className={inputCls} value={form.priority ?? "mid"} onChange={(e) => set("priority", e.target.value)}>
|
||
{[["high","高"],["mid","中"],["low","低"]].map(([v, l]) => (
|
||
<option key={v} value={v}>{l}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-span-3">
|
||
<label className={labelCls}>技术栈(逗号分隔)</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.tech_stack ?? ""}
|
||
placeholder="React, TypeScript, Rust"
|
||
onChange={(e) => set("tech_stack", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-span-3">
|
||
<label className={labelCls}>分类标签</label>
|
||
<div className="flex flex-wrap gap-1.5 min-h-[36px]">
|
||
{allTags.length === 0 && (
|
||
<p className="text-xs text-gray-300">请先在「管理-标签」里创建标签</p>
|
||
)}
|
||
{allTags.map((tag) => {
|
||
const selected = selectedTagIds.includes(tag.id);
|
||
return (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
onClick={() => setSelectedTagIds((prev) =>
|
||
selected ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
|
||
)}
|
||
className={`px-2.5 py-1 rounded-full text-xs font-medium border-2 transition-all ${
|
||
selected ? "text-white border-transparent" : "bg-white border-gray-200 text-gray-500"
|
||
}`}
|
||
style={selected ? { backgroundColor: tag.color, borderColor: tag.color } : {}}
|
||
>
|
||
{tag.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 工作区配置 */}
|
||
<div className="border-t border-gray-100 pt-4">
|
||
<p className={sectionCls}>工作区配置</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>当前聚焦</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.recent_focus ?? ""}
|
||
placeholder="正在开发登录模块"
|
||
onChange={(e) => set("recent_focus", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>启动命令</label>
|
||
<textarea
|
||
className={`${inputCls} font-mono resize-none`}
|
||
rows={2}
|
||
value={form.startup_commands ?? ""}
|
||
placeholder="pnpm dev"
|
||
onChange={(e) => set("startup_commands", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab: 部署信息 ── */}
|
||
{tab === "deploy" && (
|
||
<div className="px-6 py-5 space-y-5">
|
||
|
||
{/* 访问地址 */}
|
||
<div>
|
||
<p className={sectionCls}>访问地址</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className={labelCls}>生产环境 URL</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.prod_url ?? ""}
|
||
placeholder="https://example.com"
|
||
onChange={(e) => set("prod_url", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>测试 / 预发环境 URL</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.staging_url ?? ""}
|
||
placeholder="https://staging.example.com"
|
||
onChange={(e) => set("staging_url", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>服务器备注</label>
|
||
<input
|
||
className={inputCls}
|
||
value={form.server_note ?? ""}
|
||
placeholder="阿里云 ECS / SSH: root@1.2.3.4"
|
||
onChange={(e) => set("server_note", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Git 仓库 */}
|
||
<div className="border-t border-gray-100 pt-4">
|
||
<p className={sectionCls}>Git 仓库</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>仓库地址</label>
|
||
<input
|
||
className={`${inputCls} font-mono`}
|
||
value={form.repo_url ?? ""}
|
||
placeholder="https://github.com/user/repo.git"
|
||
onChange={(e) => set("repo_url", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className={labelCls}>公司上游仓库地址 <span className="text-gray-400 font-normal">(可选)</span></label>
|
||
<input
|
||
className={`${inputCls} font-mono`}
|
||
value={form.upstream_url ?? ""}
|
||
placeholder="https://github.com/company/repo.git"
|
||
onChange={(e) => set("upstream_url", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={labelCls}>默认分支</label>
|
||
<input
|
||
className={`${inputCls} font-mono`}
|
||
value={form.default_branch ?? ""}
|
||
placeholder="main"
|
||
onChange={(e) => set("default_branch", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
</div>{/* end scrollable area */}
|
||
|
||
{/* ── 底部操作(固定)── */}
|
||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 shrink-0">
|
||
<button onClick={closeEdit} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{saving ? "保存中…" : "保存"}
|
||
</button>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|