Compare commits
2 Commits
ccf87ae09a
...
9881b568b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9881b568b6 | ||
|
|
bc7bcb2fe1 |
@ -406,6 +406,14 @@ modules:
|
|||||||
note: "N1-N5 全部完成(2026-07-02):六组导航 + 卡片分层 + 洞察页 + Gitea/仓库归位"
|
note: "N1-N5 全部完成(2026-07-02):六组导航 + 卡片分层 + 洞察页 + Gitea/仓库归位"
|
||||||
position: [3750, 300]
|
position: [3750, 300]
|
||||||
|
|
||||||
|
- id: manage-multi-view
|
||||||
|
name: 管理页多维表视图
|
||||||
|
area: frontend
|
||||||
|
status: planned
|
||||||
|
progress: 0
|
||||||
|
note: "项目(默认A+可选B)& 产品组(默认F+可选G)四视图,多对一关系,用户可新建"
|
||||||
|
position: [4000, 0]
|
||||||
|
|
||||||
edges:
|
edges:
|
||||||
# 前端依赖
|
# 前端依赖
|
||||||
- from: workspace-mgmt
|
- from: workspace-mgmt
|
||||||
@ -608,6 +616,14 @@ edges:
|
|||||||
to: source-library
|
to: source-library
|
||||||
type: dependency
|
type: dependency
|
||||||
|
|
||||||
|
# 管理页多维表视图
|
||||||
|
- from: project-mgmt
|
||||||
|
to: manage-multi-view
|
||||||
|
type: dependency
|
||||||
|
- from: tags-groups
|
||||||
|
to: manage-multi-view
|
||||||
|
type: dependency
|
||||||
|
|
||||||
# 云产品扩展
|
# 云产品扩展
|
||||||
- from: cloud-products
|
- from: cloud-products
|
||||||
to: cloud-docker-apps
|
to: cloud-docker-apps
|
||||||
|
|||||||
58
.blueprint/modules/manage-multi-view.md
Normal file
58
.blueprint/modules/manage-multi-view.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
id: manage-multi-view
|
||||||
|
name: 管理页多维表视图
|
||||||
|
status: planned
|
||||||
|
progress: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
为"管理 → 项目"和"管理 → 产品组"两个实体分别提供多维表视图。
|
||||||
|
数据关系:多个项目对应一个产品组(多对一)。
|
||||||
|
|
||||||
|
视图设计原则:
|
||||||
|
- 默认视图:系统预置,开箱即用,同时作为用户理解数据关系的教材
|
||||||
|
- 新建视图:用户在默认视图基础上选择预定义模板,按需构建个性化视角
|
||||||
|
- 关系表达分工:层级关系由导航路径承载;量的关系由视图的关联字段/分组列承载
|
||||||
|
|
||||||
|
## 视图配置
|
||||||
|
|
||||||
|
### 项目侧
|
||||||
|
|
||||||
|
| 类型 | 视图名 | 分组方式 | 核心字段 |
|
||||||
|
|------|--------|---------|---------|
|
||||||
|
| 默认 | 全览表格(A) | 无分组,按名称排序 | 项目名、产品组、状态、负责人 |
|
||||||
|
| 可新建 | 按产品组分组(B) | 分组=产品组,含分组计数 | 项目名、状态、负责人 |
|
||||||
|
|
||||||
|
### 产品组侧
|
||||||
|
|
||||||
|
| 类型 | 视图名 | 核心字段 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 默认 | 产品组概览(F) | 组名、项目数量(汇总)、负责人 |
|
||||||
|
| 可新建 | 产品组健康度(G) | 组名、进行中数量、已完成数量、搁置数量 |
|
||||||
|
|
||||||
|
## 任务卡
|
||||||
|
|
||||||
|
### 📋 项目默认视图:全览表格(A)
|
||||||
|
- complexity: M
|
||||||
|
- status: todo
|
||||||
|
- files: src/components/manage/ProjectListView.tsx
|
||||||
|
- acceptance: [UI] 打开"管理→项目",默认渲染全览表格,展示"项目名、产品组、状态、负责人"四列,无分组,按名称排序,行数与数据库一致
|
||||||
|
|
||||||
|
### 📋 产品组默认视图:产品组概览(F)
|
||||||
|
- complexity: M
|
||||||
|
- status: todo
|
||||||
|
- files: src/components/manage/ProductGroupView.tsx
|
||||||
|
- acceptance: [UI] 打开"管理→产品组",默认渲染概览表格,展示"组名、项目数量(汇总)、负责人"三列,项目数量值与实际关联记录数一致
|
||||||
|
|
||||||
|
### 📋 用户新建视图:项目侧(B)
|
||||||
|
- complexity: M
|
||||||
|
- status: todo
|
||||||
|
- files: src/components/manage/ProjectListView.tsx
|
||||||
|
- acceptance: [UI] 点击"新建视图",可选"按产品组分组"模板,创建后表格按产品组分组展示,分组头显示该组项目数量
|
||||||
|
|
||||||
|
### 📋 用户新建视图:产品组侧(G)
|
||||||
|
- complexity: M
|
||||||
|
- status: todo
|
||||||
|
- files: src/components/manage/ProductGroupView.tsx
|
||||||
|
- acceptance: [UI] 点击"新建视图",可选"产品组健康度"模板,创建后展示"组名、进行中、已完成、搁置"四列数量汇总
|
||||||
@ -153,49 +153,24 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
|||||||
| TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 |
|
| TypeScript 类型检查 | `pnpm typecheck` | 无输出,exit 0 |
|
||||||
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
|
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
|
||||||
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
|
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
|
||||||
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表(21 个工具) | 返回 JSON,无 error 字段 |
|
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 |
|
||||||
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
|
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
|
||||||
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |
|
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |
|
||||||
|
|
||||||
## 分发给子项目的方案
|
## 分发给子项目的方案
|
||||||
|
|
||||||
### 完整接入(长期项目推荐)
|
接入包只有一档,要么接,要么不接。
|
||||||
|
|
||||||
```
|
```
|
||||||
接入包
|
接入包
|
||||||
├── AGENTS.md 模板 ← 含方针 + 按钮清单占位 + 降级约定
|
├── AGENTS.md 模板 ← 含方针 + 按钮清单占位 + 降级约定
|
||||||
├── .blueprint/CONVENTIONS.md ← 含 acceptance 写法规范
|
├── .blueprint/CONVENTIONS.md ← 含 acceptance 写法规范 + 蓝图工作流
|
||||||
├── lefthook.yml ← git hooks(模型无关验证层)
|
├── lefthook.yml ← git hooks(模型无关验证层)
|
||||||
└── .gitea/workflows/ci.yml ← CI/CD(模型无关验证层)
|
└── .gitea/workflows/ci.yml ← CI/CD(模型无关验证层)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 最小接入(轻量项目)
|
子项目接入后需要自己填入:
|
||||||
|
- AGENTS.md 里 agent 按钮清单的具体命令和通过标准
|
||||||
```
|
|
||||||
接入包
|
|
||||||
├── AGENTS.md 模板 ← 含方针 + 降级约定
|
|
||||||
├── .agent/task.md 模板 ← 最小目标持久化(跨会话续接)
|
|
||||||
└── lefthook.yml ← 最小验证层
|
|
||||||
```
|
|
||||||
|
|
||||||
`.agent/task.md` 格式:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 当前目标
|
|
||||||
<人填写>
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
- <可执行断言 1>
|
|
||||||
- <可执行断言 2>
|
|
||||||
|
|
||||||
## 状态
|
|
||||||
in_progress / blocked / done
|
|
||||||
|
|
||||||
## 阻塞原因(blocked 时填)
|
|
||||||
自验失败 N 次,具体问题:<描述>
|
|
||||||
需要人决策:<具体问题>
|
|
||||||
```
|
|
||||||
|
|
||||||
子项目只需填入:
|
|
||||||
- AGENTS.md 里 agent 按钮清单的具体命令
|
|
||||||
- 任务卡 acceptance 字段的可执行断言
|
- 任务卡 acceptance 字段的可执行断言
|
||||||
|
|
||||||
|
> 没有蓝图就没有飞轮数据,炼境对该项目的感知断链。不接入蓝图的项目不应通过炼境分发接入包,直接使用 AGENTS.md 开放规范即可。
|
||||||
|
|||||||
@ -64,11 +64,22 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
const [delTarget, setDelTarget] = useState<string | null>(null);
|
const [delTarget, setDelTarget] = useState<string | null>(null);
|
||||||
const [deleteLocal, setDeleteLocal] = useState(false);
|
const [deleteLocal, setDeleteLocal] = useState(false);
|
||||||
const [showNewProject, setShowNewProject] = useState(false);
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
|
const [view, setView] = useState<"all" | "by-group">("all");
|
||||||
|
|
||||||
const { data: projects = [] } = useQuery({
|
const { data: projects = [] } = useQuery({
|
||||||
queryKey: ["projects", spaceId],
|
queryKey: ["projects", spaceId],
|
||||||
queryFn: () => getProjects(undefined, 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 () => {
|
const confirmDelete = async () => {
|
||||||
if (!delTarget) return;
|
if (!delTarget) return;
|
||||||
@ -79,11 +90,29 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
showToast("已删除");
|
showToast("已删除");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROJECT_VIEWS = [
|
||||||
|
{ key: "all", label: "全览表格" },
|
||||||
|
{ key: "by-group", label: "按产品组分组" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-sm font-semibold text-gray-700">共 {projects.length} 个项目</h2>
|
<h2 className="text-sm font-semibold text-gray-700">共 {projects.length} 个项目</h2>
|
||||||
<div className="flex gap-2">
|
<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
|
<button
|
||||||
onClick={() => setShowNewProject(true)}
|
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"
|
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 bg-indigo-50 text-sm hover:bg-indigo-100"
|
||||||
@ -99,76 +128,189 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
{/* 全览表格视图 */}
|
||||||
<table className="w-full text-sm">
|
{view === "all" && (
|
||||||
<thead>
|
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<table className="w-full text-sm">
|
||||||
{["名称 / 描述", "类型", "状态", "优先级", "技术栈", "本地路径", "操作"].map((h) => (
|
<thead>
|
||||||
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
{h}
|
{["名称 / 描述", "产品组", "状态", "优先级", "技术栈", "本地路径", "操作"].map((h) => (
|
||||||
</th>
|
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap">
|
||||||
))}
|
{h}
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{projects.map((p) => (
|
|
||||||
<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">
|
|
||||||
{p.type && (
|
|
||||||
<span className="px-2 py-0.5 rounded-md bg-indigo-50 text-indigo-700 text-xs font-medium border border-indigo-100">
|
|
||||||
{p.type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-100">
|
||||||
</table>
|
{projects.map((p) => {
|
||||||
{projects.length === 0 && (
|
const groupNames = getProjectGroupNames(p.id);
|
||||||
<div className="text-center py-20 text-gray-400 text-sm">暂无项目</div>
|
return (
|
||||||
)}
|
<tr key={p.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
|
||||||
</div>
|
<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 && (
|
{delTarget && (
|
||||||
<Dialog title="确认删除" onClose={() => { setDelTarget(null); setDeleteLocal(false); }} size="sm">
|
<Dialog title="确认删除" onClose={() => { setDelTarget(null); setDeleteLocal(false); }} size="sm">
|
||||||
@ -204,6 +346,7 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
function GroupsTab({ spaceId }: { spaceId?: string }) {
|
function GroupsTab({ spaceId }: { spaceId?: string }) {
|
||||||
const showToast = useUIStore((s) => s.showToast);
|
const showToast = useUIStore((s) => s.showToast);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const [groupView, setGroupView] = useState<"overview" | "health" | "detail">("overview");
|
||||||
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
|
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
|
||||||
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
|
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
|
||||||
const [delGroup, setDelGroup] = useState<string | null>(null);
|
const [delGroup, setDelGroup] = useState<string | null>(null);
|
||||||
@ -250,130 +393,233 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
qc.invalidateQueries({ queryKey: ["groups", spaceId] });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-sm font-semibold text-gray-700">共 {groups.length} 个产品组</h2>
|
<h2 className="text-sm font-semibold text-gray-700">共 {groups.length} 个产品组</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setGroupModal(null)}
|
<div className="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||||
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700"
|
{GROUP_VIEWS.map(({ key, label }) => (
|
||||||
>
|
<button
|
||||||
+ 新建产品组
|
key={key}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* 产品组概览视图 */}
|
||||||
{groups.map((g, idx) => {
|
{groupView === "overview" && (
|
||||||
const members = maps.filter((m) => m.group_id === g.id);
|
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
||||||
return (
|
<table className="w-full text-sm">
|
||||||
<div key={g.id} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
<thead>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-gray-50/60">
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{["产品组名称", "项目数量", "描述", "操作"].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">
|
||||||
<div className="flex flex-col gap-0.5 shrink-0">
|
{h}
|
||||||
<button
|
</th>
|
||||||
onClick={() => move(idx, -1)}
|
))}
|
||||||
disabled={idx === 0}
|
</tr>
|
||||||
className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]"
|
</thead>
|
||||||
title="上移"
|
<tbody className="divide-y divide-gray-100">
|
||||||
>▲</button>
|
{groups.map((g) => {
|
||||||
<button
|
const count = maps.filter((m) => m.group_id === g.id).length;
|
||||||
onClick={() => move(idx, 1)}
|
return (
|
||||||
disabled={idx === groups.length - 1}
|
<tr key={g.id} className="bg-white hover:bg-gray-50/60 transition-colors group">
|
||||||
className="text-gray-300 hover:text-gray-600 disabled:opacity-20 leading-none text-[10px]"
|
<td className="px-5 py-4">
|
||||||
title="下移"
|
<div className="font-semibold text-gray-900">{g.name}</div>
|
||||||
>▼</button>
|
<div className="text-xs font-mono text-gray-400 mt-0.5">{g.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-50 text-blue-700 text-sm font-semibold border border-blue-100">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</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>
|
</div>
|
||||||
<span className="font-semibold text-gray-800">{g.name}</span>
|
{renderGroupActions(g)}
|
||||||
<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>
|
</div>
|
||||||
{g.description && (
|
<div className="px-5 py-4 flex flex-wrap gap-2">
|
||||||
<span className="text-xs text-gray-400 border-l border-gray-200 pl-3 truncate">{g.description}</span>
|
{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>
|
||||||
<div className="flex gap-2 shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleInspect(g.id)}
|
|
||||||
disabled={inspecting.has(g.id)}
|
|
||||||
className="px-3 py-1.5 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-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors">
|
|
||||||
+ 成员
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setGroupModal(g)} 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={() => setDelGroup(g.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>
|
|
||||||
</div>
|
</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);
|
{groups.length === 0 && (
|
||||||
const hasPath = !!(p?.win_path || p?.wsl_path);
|
<div className="text-center py-16 text-gray-400 text-sm">暂无产品组</div>
|
||||||
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">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{/* MCP 注入状态 */}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Group modal */}
|
|
||||||
{groupModal !== undefined && (
|
{groupModal !== undefined && (
|
||||||
<GroupFormModal
|
<GroupFormModal
|
||||||
group={groupModal}
|
group={groupModal}
|
||||||
@ -386,8 +632,6 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add member modal */}
|
|
||||||
{memberTarget && (
|
{memberTarget && (
|
||||||
<AddMemberModal
|
<AddMemberModal
|
||||||
group={memberTarget}
|
group={memberTarget}
|
||||||
@ -401,7 +645,6 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{delGroup && (
|
{delGroup && (
|
||||||
<Dialog title="确认删除产品组" onClose={() => setDelGroup(null)} size="sm">
|
<Dialog title="确认删除产品组" onClose={() => setDelGroup(null)} size="sm">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user