上一会话遗留的完整功能补提交: - 新增 4 个三端体系内置模板:React Web / React + Capacitor / React + Tauri (Antd) / React Tri-Platform(同一架构按需起步) - tech_stack 升级为分类 JSON 格式(客户端/语言/框架), 新增 techStackUtils.renderTechStack 兼容旧逗号格式, ManagePage / NewProjectModal 接线,TemplateEditor 占位符同步 Feature-Confirmed: true
889 lines
43 KiB
TypeScript
889 lines
43 KiB
TypeScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|