dev-manager-tauri/src/components/manage/ManagePage.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

889 lines
43 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 } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
deleteProject, getGroups, getGroupMaps, getProjects, getSpaces,
deleteGroup, addGroupMember, removeGroupMember,
createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate,
getGroupMentorReport,
type ProductGroup, type Project, type ProjectTemplate,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { Dialog } from "../ui/Dialog";
import { TemplateEditor } from "./TemplateEditor";
import { NewProjectModal } from "./NewProjectModal";
import { renderTechStack } from "./techStackUtils";
import { TagsManager } from "./TagsManager";
import { RegistryTab } from "../dashboard/RepoRegistryModal";
export function ManagePage() {
const [tab, setTab] = useState<"projects" | "groups" | "templates" | "tags" | "repos">("projects");
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
const currentSpaceId = spaces.find((s) => s.is_current)?.id;
const TAB_LABELS = { projects: "项目", groups: "产品组", templates: "项目模板", tags: "标签", repos: "仓库" };
return (
<div className="flex flex-col h-full min-h-0">
<div className="shrink-0 border-b border-gray-200 bg-white px-8 flex gap-1">
{(["projects", "groups", "templates", "tags", "repos"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === t ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{TAB_LABELS[t]}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="w-full px-8 py-8 pb-16">
{tab === "projects" && <ProjectsTab spaceId={currentSpaceId} />}
{tab === "groups" && <GroupsTab spaceId={currentSpaceId} />}
{tab === "templates" && <TemplatesTab spaceId={currentSpaceId} />}
{tab === "tags" && <TagsManager />}
{tab === "repos" && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm flex flex-col max-h-[70vh]">
<RegistryTab />
</div>
)}
</div>
</div>
</div>
);
}
/* ── Projects Tab ──────────────────────────────────────────────────────────── */
function ProjectsTab({ spaceId }: { spaceId?: string }) {
const openEdit = useUIStore((s) => s.openEdit);
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [delTarget, setDelTarget] = useState<string | null>(null);
const [deleteLocal, setDeleteLocal] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [view, setView] = useState<"all" | "by-group">("all");
const { data: projects = [] } = useQuery({
queryKey: ["projects", spaceId],
queryFn: () => getProjects(undefined, spaceId),
});
const { data: groups = [] } = useQuery({
queryKey: ["groups", spaceId],
queryFn: () => getGroups(spaceId),
});
const { data: maps = [] } = useQuery({ queryKey: ["maps"], queryFn: getGroupMaps });
const getProjectGroupNames = (projectId: string): string[] => {
const groupIds = maps.filter((m) => m.project_id === projectId).map((m) => m.group_id);
return groupIds.map((id) => groups.find((g) => g.id === id)?.name ?? "").filter(Boolean);
};
const confirmDelete = async () => {
if (!delTarget) return;
await deleteProject(delTarget, deleteLocal);
setDelTarget(null);
setDeleteLocal(false);
qc.invalidateQueries({ queryKey: ["projects"] });
showToast("已删除");
};
const PROJECT_VIEWS = [
{ key: "all", label: "全览表格" },
{ key: "by-group", label: "按产品组分组" },
] as const;
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700"> {projects.length} </h2>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-gray-200 overflow-hidden">
{PROJECT_VIEWS.map(({ key, label }) => (
<button
key={key}
onClick={() => setView(key)}
className={`px-3 py-1.5 text-xs transition-colors ${
view === key ? "bg-blue-50 text-blue-600 font-medium" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
}`}
>
{label}
</button>
))}
</div>
<button
onClick={() => setShowNewProject(true)}
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 bg-indigo-50 text-sm hover:bg-indigo-100"
>
🔨
</button>
<button
onClick={() => openEdit(null)}
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
>
+
</button>
</div>
</div>
{/* 全览表格视图 */}
{view === "all" && (
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{["名称 / 描述", "产品组", "状态", "优先级", "技术栈", "本地路径", "操作"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{projects.map((p) => {
const groupNames = getProjectGroupNames(p.id);
return (
<tr key={p.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
<td className="px-5 py-4">
<div className="font-semibold text-gray-900">{p.name}</div>
{p.description && (
<div className="text-xs text-gray-400 mt-0.5 max-w-[200px] truncate">{p.description}</div>
)}
</td>
<td className="px-5 py-4">
<div className="flex flex-wrap gap-1">
{groupNames.length > 0 ? groupNames.map((gName) => (
<span key={gName} className="px-2 py-0.5 rounded-md bg-blue-50 text-blue-700 text-xs font-medium border border-blue-100">
{gName}
</span>
)) : (
<span className="text-xs text-gray-300"></span>
)}
</div>
</td>
<td className="px-5 py-4"><StatusBadge status={p.status} /></td>
<td className="px-5 py-4"><PriorityBadge priority={p.priority} /></td>
<td className="px-5 py-4 max-w-[160px]">
<div className="flex flex-wrap gap-1">
{(p.tech_stack ?? "").split(",").filter(Boolean).map((t) => (
<span key={t} className="px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">{t.trim()}</span>
))}
</div>
</td>
<td className="px-5 py-4 max-w-[200px]">
<div className="flex flex-col gap-1 text-xs font-mono">
{p.wsl_path && (
<div className="flex items-center gap-1.5 truncate">
<span className="shrink-0 text-[11px] font-semibold text-green-700 bg-green-50 border border-green-200 rounded px-1">WSL</span>
<span className="text-gray-500 truncate" title={p.wsl_path}>{p.wsl_path}</span>
</div>
)}
{p.win_path && (
<div className="flex items-center gap-1.5 truncate">
<span className="shrink-0 text-[11px] font-semibold text-blue-700 bg-blue-50 border border-blue-200 rounded px-1">WIN</span>
<span className="text-gray-500 truncate" title={p.win_path}>{p.win_path}</span>
</div>
)}
</div>
</td>
<td className="px-5 py-4">
<div className="flex gap-2 opacity-70 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(p)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors">
</button>
<button onClick={() => setDelTarget(p.id)} className="px-3 py-1.5 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50 transition-colors">
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{projects.length === 0 && (
<div className="text-center py-20 text-gray-400 text-sm"></div>
)}
</div>
)}
{/* 按产品组分组视图 */}
{view === "by-group" && (
<div className="space-y-6">
{groups.map((g) => {
const groupProjects = maps
.filter((m) => m.group_id === g.id)
.map((m) => projects.find((p) => p.id === m.project_id))
.filter((p): p is Project => p !== undefined);
return (
<div key={g.id} className="rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-5 py-3 bg-gray-50 border-b border-gray-200 flex items-center gap-2">
<span className="font-semibold text-gray-700">{g.name}</span>
<span className="text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2 py-0.5">
{groupProjects.length}
</span>
{g.description && (
<span className="text-xs text-gray-400 border-l border-gray-200 pl-2">{g.description}</span>
)}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
{["名称 / 描述", "状态", "优先级", "操作"].map((h) => (
<th key={h} className="px-5 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{groupProjects.map((p) => (
<tr key={p.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
<td className="px-5 py-3">
<div className="font-medium text-gray-900">{p.name}</div>
{p.description && (
<div className="text-xs text-gray-400 mt-0.5 max-w-[240px] truncate">{p.description}</div>
)}
</td>
<td className="px-5 py-3"><StatusBadge status={p.status} /></td>
<td className="px-5 py-3"><PriorityBadge priority={p.priority} /></td>
<td className="px-5 py-3">
<div className="flex gap-2 opacity-60 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(p)} className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100"></button>
<button onClick={() => setDelTarget(p.id)} className="px-2.5 py-1 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50"></button>
</div>
</td>
</tr>
))}
{groupProjects.length === 0 && (
<tr>
<td colSpan={4} className="px-5 py-4 text-xs text-gray-300 text-center"></td>
</tr>
)}
</tbody>
</table>
</div>
);
})}
{(() => {
const ungrouped = projects.filter((p) => !maps.some((m) => m.project_id === p.id));
if (ungrouped.length === 0) return null;
return (
<div className="rounded-xl border border-dashed border-gray-200 overflow-hidden">
<div className="px-5 py-3 bg-gray-50/50 border-b border-gray-200 flex items-center gap-2">
<span className="font-semibold text-gray-400"></span>
<span className="text-xs text-gray-400 bg-white border border-gray-200 rounded-full px-2 py-0.5">{ungrouped.length}</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
{["名称 / 描述", "状态", "优先级", "操作"].map((h) => (
<th key={h} className="px-5 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{ungrouped.map((p) => (
<tr key={p.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
<td className="px-5 py-3">
<div className="font-medium text-gray-900">{p.name}</div>
{p.description && <div className="text-xs text-gray-400 mt-0.5 max-w-[240px] truncate">{p.description}</div>}
</td>
<td className="px-5 py-3"><StatusBadge status={p.status} /></td>
<td className="px-5 py-3"><PriorityBadge priority={p.priority} /></td>
<td className="px-5 py-3">
<div className="flex gap-2 opacity-60 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEdit(p)} className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100"></button>
<button onClick={() => setDelTarget(p.id)} className="px-2.5 py-1 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
{groups.length === 0 && projects.length === 0 && (
<div className="text-center py-16 text-gray-400 text-sm"></div>
)}
</div>
)}
{delTarget && (
<Dialog title="确认删除" onClose={() => { setDelTarget(null); setDeleteLocal(false); }} size="sm">
<div className="px-6 py-4">
<p className="text-sm text-gray-600 mb-2"></p>
<p className="text-xs font-mono text-gray-400 mb-4">{delTarget}</p>
<label className="flex items-center gap-2 mb-6 cursor-pointer select-none">
<input
type="checkbox"
checked={deleteLocal}
onChange={(e) => setDeleteLocal(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-red-600"
/>
<span className="text-sm text-red-600"></span>
</label>
<div className="flex justify-end gap-3">
<button onClick={() => { setDelTarget(null); setDeleteLocal(false); }} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button onClick={confirmDelete} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700"></button>
</div>
</div>
</Dialog>
)}
{showNewProject && (
<NewProjectModal spaceId={spaceId} onClose={() => setShowNewProject(false)} />
)}
</>
);
}
/* ── Groups Tab ────────────────────────────────────────────────────────────── */
function GroupsTab({ spaceId }: { spaceId?: string }) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [groupView, setGroupView] = useState<"overview" | "health" | "detail">("overview");
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
const [delGroup, setDelGroup] = useState<string | null>(null);
const [inspecting, setInspecting] = useState<Set<string>>(new Set());
const handleInspect = async (groupId: string) => {
setInspecting((prev) => new Set(prev).add(groupId));
try {
const report = await getGroupMentorReport(groupId);
await navigator.clipboard.writeText(report);
showToast("已复制,可粘贴给 Claude 进行全组分析");
} catch (e) {
showToast(`❌ 巡检失败: ${e}`);
} finally {
setInspecting((prev) => { const s = new Set(prev); s.delete(groupId); return s; });
}
};
const { data: groups = [] } = useQuery({
queryKey: ["groups", spaceId],
queryFn: () => getGroups(spaceId),
});
const { data: maps = [] } = useQuery({ queryKey: ["maps"], queryFn: getGroupMaps });
const { data: projects = [] } = useQuery({
queryKey: ["projects", spaceId],
queryFn: () => getProjects(undefined, spaceId),
});
const confirmDel = async () => {
if (!delGroup) return;
await deleteGroup(delGroup);
setDelGroup(null);
qc.invalidateQueries({ queryKey: ["groups", spaceId] });
qc.invalidateQueries({ queryKey: ["maps"] });
showToast("已删除");
};
const move = async (index: number, dir: -1 | 1) => {
const next = index + dir;
if (next < 0 || next >= groups.length) return;
const ids = groups.map((g) => g.id);
[ids[index], ids[next]] = [ids[next], ids[index]];
await reorderGroups(ids);
qc.invalidateQueries({ queryKey: ["groups", spaceId] });
};
const GROUP_VIEWS = [
{ key: "overview", label: "产品组概览" },
{ key: "health", label: "健康度" },
{ key: "detail", label: "成员详情" },
] as const;
const renderGroupActions = (g: ProductGroup) => (
<div className="flex gap-1.5 shrink-0">
<button
onClick={() => handleInspect(g.id)}
disabled={inspecting.has(g.id)}
className="px-2.5 py-1 rounded-lg border border-indigo-200 text-xs text-indigo-600 hover:bg-indigo-50 transition-colors disabled:opacity-50 flex items-center gap-1"
title="聚合全组导师报告并复制到剪贴板"
>
{inspecting.has(g.id) ? (
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
) : "🔍"}
</button>
<button onClick={() => setMemberTarget(g)} className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100">+ </button>
<button onClick={() => setGroupModal(g)} className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100"></button>
<button onClick={() => setDelGroup(g.id)} className="px-2.5 py-1 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50"></button>
</div>
);
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700"> {groups.length} </h2>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-gray-200 overflow-hidden">
{GROUP_VIEWS.map(({ key, label }) => (
<button
key={key}
onClick={() => setGroupView(key)}
className={`px-3 py-1.5 text-xs transition-colors ${
groupView === key ? "bg-blue-50 text-blue-600 font-medium" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
}`}
>
{label}
</button>
))}
</div>
<button
onClick={() => setGroupModal(null)}
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
>
+
</button>
</div>
</div>
{/* 产品组概览视图 */}
{groupView === "overview" && (
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{["产品组名称", "项目", "描述", "操作"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{groups.map((g) => {
const groupProjectEntries = maps
.filter((m) => m.group_id === g.id)
.map((m) => { const p = projects.find((x) => x.id === m.project_id); return p ? { id: p.id, name: p.name } : null; })
.filter((e): e is { id: string; name: string } => e !== null);
return (
<tr key={g.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
<td className="px-5 py-4">
<div className="font-semibold text-gray-900">{g.name}</div>
<div className="text-xs font-mono text-gray-400 mt-0.5">{g.id}</div>
</td>
<td className="px-5 py-4 max-w-[320px]">
<div className="flex flex-wrap gap-1">
{groupProjectEntries.length > 0 ? groupProjectEntries.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-blue-50 text-blue-700 text-xs font-medium border border-blue-100">
{name}
<button
onClick={async () => { await removeGroupMember(id, g.id); qc.invalidateQueries({ queryKey: ["maps"] }); }}
className="text-blue-400 hover:text-red-400 transition-colors leading-none"
>×</button>
</span>
)) : (
<span className="text-xs text-gray-300"></span>
)}
</div>
</td>
<td className="px-5 py-4 max-w-[280px]">
<span className="text-sm text-gray-500 truncate block">{g.description ?? "—"}</span>
</td>
<td className="px-5 py-4">
<div className="opacity-60 group-hover:opacity-100 transition-opacity">
{renderGroupActions(g)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{groups.length === 0 && (
<div className="text-center py-20 text-gray-400 text-sm"></div>
)}
</div>
)}
{/* 健康度视图 */}
{groupView === "health" && (
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{["产品组名称", "进行中", "维护中", "暂停", "已归档", "合计"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{groups.map((g) => {
const groupProjectIds = maps.filter((m) => m.group_id === g.id).map((m) => m.project_id);
const gps = groupProjectIds
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => p !== undefined);
const counts = {
active: gps.filter((p) => p.status === "active").length,
maintenance: gps.filter((p) => p.status === "maintenance").length,
paused: gps.filter((p) => p.status === "paused").length,
archived: gps.filter((p) => p.status === "archived").length,
};
return (
<tr key={g.id} className="bg-white hover:bg-gray-50/60 transition-colors">
<td className="px-5 py-4 font-semibold text-gray-900">{g.name}</td>
<td className="px-5 py-4">
{counts.active > 0
? <span className="px-2 py-0.5 rounded-full bg-green-100 text-green-800 text-xs font-medium">{counts.active}</span>
: <span className="text-gray-300 text-xs"></span>}
</td>
<td className="px-5 py-4">
{counts.maintenance > 0
? <span className="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 text-xs font-medium">{counts.maintenance}</span>
: <span className="text-gray-300 text-xs"></span>}
</td>
<td className="px-5 py-4">
{counts.paused > 0
? <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs font-medium">{counts.paused}</span>
: <span className="text-gray-300 text-xs"></span>}
</td>
<td className="px-5 py-4">
{counts.archived > 0
? <span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs font-medium">{counts.archived}</span>
: <span className="text-gray-300 text-xs"></span>}
</td>
<td className="px-5 py-4">
<span className="text-sm font-medium text-gray-600">{Object.values(counts).reduce((a, b) => a + b, 0)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
{groups.length === 0 && (
<div className="text-center py-20 text-gray-400 text-sm"></div>
)}
</div>
)}
{/* 成员详情视图 */}
{groupView === "detail" && (
<div className="space-y-4">
{groups.map((g, idx) => {
const members = maps.filter((m) => m.group_id === g.id);
return (
<div key={g.id} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-gray-50/60">
<div className="flex items-center gap-3 min-w-0">
<div className="flex flex-col gap-0.5 shrink-0">
<button onClick={() => move(idx, -1)} disabled={idx === 0} className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]" title="上移"></button>
<button onClick={() => move(idx, 1)} disabled={idx === groups.length - 1} className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]" title="下移"></button>
</div>
<span className="font-semibold text-gray-800">{g.name}</span>
<span className="text-xs font-mono text-gray-400 bg-white border border-gray-200 rounded px-1.5 py-0.5">{g.id}</span>
{g.description && (
<span className="text-xs text-gray-400 border-l border-gray-200 pl-3 truncate">{g.description}</span>
)}
</div>
{renderGroupActions(g)}
</div>
<div className="px-5 py-4 flex flex-wrap gap-2">
{members.map((m) => {
const p = projects.find((x) => x.id === m.project_id);
const hasPath = !!(p?.win_path || p?.wsl_path);
return p ? (
<div key={m.project_id} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-50 border border-gray-200 text-xs">
<span className="font-medium text-gray-700">{p.name}</span>
{p.platform && (
<span className={`px-1.5 py-0.5 rounded border text-xs ${
p.platform === "wsl" ? "bg-orange-50 text-orange-600 border-orange-200" : "bg-blue-50 text-blue-600 border-blue-200"
}`}>
{p.platform === "wsl" ? "WSL" : "Win"}
</span>
)}
{hasPath ? (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded border bg-green-50 text-green-600 border-green-200" title={`MCP 已注入:${p.win_path || p.wsl_path}`}>
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
MCP
</span>
) : (
<span className="px-1.5 py-0.5 rounded border bg-gray-100 text-gray-400 border-gray-200" title="项目未配置路径MCP 注入已跳过"></span>
)}
{m.role && <span className="text-gray-400 border-l border-gray-200 pl-2">{m.role}</span>}
<button
onClick={async () => { await removeGroupMember(m.project_id, g.id); qc.invalidateQueries({ queryKey: ["maps"] }); }}
className="text-gray-300 hover:text-red-400 transition-colors ml-0.5"
>×</button>
</div>
) : null;
})}
{members.length === 0 && (
<span className="text-xs text-gray-400 py-1">+ </span>
)}
</div>
</div>
);
})}
{groups.length === 0 && (
<div className="text-center py-16 text-gray-400 text-sm"></div>
)}
</div>
)}
{groupModal !== undefined && (
<GroupFormModal
group={groupModal}
spaceId={spaceId}
onClose={() => setGroupModal(undefined)}
onSave={() => {
setGroupModal(undefined);
qc.invalidateQueries({ queryKey: ["groups", spaceId] });
showToast(groupModal ? "已更新" : "已创建");
}}
/>
)}
{memberTarget && (
<AddMemberModal
group={memberTarget}
projects={projects}
existingIds={new Set(maps.filter((m) => m.group_id === memberTarget.id).map((m) => m.project_id))}
onClose={() => setMemberTarget(null)}
onSave={() => {
setMemberTarget(null);
qc.invalidateQueries({ queryKey: ["maps"] });
showToast("成员已添加");
}}
/>
)}
{delGroup && (
<Dialog title="确认删除产品组" onClose={() => setDelGroup(null)} size="sm">
<div className="px-6 py-4">
<p className="text-sm text-gray-600 mb-6"></p>
<div className="flex justify-end gap-3">
<button onClick={() => setDelGroup(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button onClick={confirmDel} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700"></button>
</div>
</div>
</Dialog>
)}
</>
);
}
function GroupFormModal({ group, spaceId, onClose, onSave }: {
group: ProductGroup | null; spaceId?: string; onClose: () => void; onSave: () => void;
}) {
const showToast = useUIStore((s) => s.showToast);
const [form, setForm] = useState({ id: group?.id ?? "", name: group?.name ?? "", description: group?.description ?? "" });
const save = async () => {
if (!form.name.trim()) { showToast("❌ 名称不能为空"); return; }
if (!group && !form.id.trim()) { showToast("❌ ID 不能为空"); return; }
if (group) await updateGroup(group.id, { name: form.name, description: form.description || undefined });
else await createGroup({ id: form.id, name: form.name, description: form.description || undefined, space_id: spaceId });
onSave();
};
return (
<Dialog title={group ? "编辑产品组" : "新建产品组"} onClose={onClose} size="sm">
<div className="px-6 py-4 space-y-3">
{!group && (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">ID *</label>
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono" value={form.id} onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))} />
<button
type="button"
onClick={() => setForm((f) => ({ ...f, id: crypto.randomUUID().replace(/-/g, "").slice(0, 8) }))}
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="block text-xs font-medium text-gray-600 mb-1"> *</label>
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600"></button>
<button onClick={save} className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"></button>
</div>
</div>
</Dialog>
);
}
function AddMemberModal({ group, projects, existingIds, onClose, onSave }: {
group: ProductGroup; projects: Project[]; existingIds: Set<string>;
onClose: () => void; onSave: () => void;
}) {
const showToast = useUIStore((s) => s.showToast);
const [selected, setSelected] = useState("");
const [role, setRole] = useState("");
const available = projects.filter((p) => !existingIds.has(p.id));
const save = async () => {
if (!selected) return;
try {
const warning = await addGroupMember(selected, group.id, role || undefined);
if (warning) {
showToast(`成员已添加,但 MCP 注入失败:${warning}`);
}
onSave();
} catch (e) {
showToast(`❌ 添加失败: ${e}`);
}
};
return (
<Dialog title={`添加成员到 ${group.name}`} onClose={onClose} size="sm">
<div className="px-6 py-4 space-y-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value=""> </option>
{available.map((p) => <option key={p.id} value={p.id}>{p.name}{p.description ? `${p.description}` : ""}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" value={role} onChange={(e) => setRole(e.target.value)} placeholder="主服务 / 前端 / SDK…" />
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600"></button>
<button onClick={save} disabled={!selected} className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"></button>
</div>
</div>
</Dialog>
);
}
/* ── Templates Tab ─────────────────────────────────────────────────────────── */
function TemplatesTab({ spaceId: _spaceId }: { spaceId?: string }) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [editorTarget, setEditorTarget] = useState<string | null | undefined>(undefined);
const [delTarget, setDelTarget] = useState<ProjectTemplate | null>(null);
const { data: templates = [] } = useQuery({
queryKey: ["templates"],
queryFn: listTemplates,
});
const handleDelete = async () => {
if (!delTarget) return;
try {
await deleteTemplate(delTarget.id);
qc.invalidateQueries({ queryKey: ["templates"] });
showToast("已删除");
} catch (e) {
showToast(`${e}`);
} finally {
setDelTarget(null);
}
};
const PLATFORM_COLOR: Record<string, string> = {
both: "bg-gray-100 text-gray-500 border-gray-200",
win: "bg-blue-50 text-blue-600 border-blue-200",
wsl: "bg-green-50 text-green-600 border-green-200",
};
const PLATFORM_LABEL: Record<string, string> = { both: "通用", win: "Windows", wsl: "WSL" };
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700"> {templates.length} </h2>
<button
onClick={() => setEditorTarget(null)}
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
>
+
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{templates.map((t) => (
<div key={t.id} className="bg-white rounded-xl border border-gray-200 shadow-sm px-5 py-4 flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<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 ${PLATFORM_COLOR[t.platform] ?? PLATFORM_COLOR.both}`}>
{PLATFORM_LABEL[t.platform] ?? t.platform}
</span>
</div>
{t.description && (
<p className="text-xs text-gray-400 mt-0.5">{t.description}</p>
)}
</div>
<div className="flex gap-1.5 shrink-0">
<button
onClick={() => setEditorTarget(t.id)}
className="px-2.5 py-1 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100"
>
{t.isBuiltin ? "查看" : "编辑"}
</button>
{!t.isBuiltin && (
<button
onClick={() => setDelTarget(t)}
className="px-2.5 py-1 rounded-lg border border-red-200 text-xs text-red-500 hover:bg-red-50"
>
</button>
)}
</div>
</div>
{t.techStack && renderTechStack(t.techStack)}
{t.initCommands && (
<code className="text-[11px] text-gray-400 font-mono">
$ {t.initCommands.split("\n")[0]}
</code>
)}
</div>
))}
{templates.length === 0 && (
<div className="col-span-2 text-center py-16 text-gray-400 text-sm"></div>
)}
</div>
{editorTarget !== undefined && (
<TemplateEditor
templateId={editorTarget}
onClose={() => setEditorTarget(undefined)}
/>
)}
{delTarget && (
<Dialog title="确认删除模板" onClose={() => setDelTarget(null)} size="sm">
<div className="px-6 py-4">
<p className="text-sm text-gray-600 mb-6">
{delTarget.name}
</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDelTarget(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button onClick={handleDelete} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700"></button>
</div>
</div>
</Dialog>
)}
</>
);
}