dev-manager-tauri/src/components/dashboard/ProjectModal.tsx
lanrtop 9d85609287
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
fix(onboard/parity): 处置 P8g 独立审查——新建入口按有效根目录弹出、Toast 置于面板之上、bypass 与错误阶段文案如实
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:06:29 +09:00

415 lines
17 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 { 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>
);
}