feat: 项目模块加删除按钮(仅超管 + 无版本可删)
- useProductStore 加 deleteProject(productId, projectId) action - ProjectRow 行右侧加三点菜单(MoreHorizontal)+ 下拉项「删除项目」 - 项目下有版本时菜单项 disabled + tooltip「项目下还有 N 个版本,不可删除」 - 仅有 project:delete 权限的角色(默认仅超管)能看到三点 icon - 点击删除 → confirm → 调 deleteProject Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, FolderKanban, Package, ChevronDown, Plus, X } from 'lucide-react';
|
import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
@@ -13,6 +13,7 @@ import { flattenProjects, flattenVersions, ProjectWithContext } from '@/lib/deri
|
|||||||
import { VersionChip } from '@/components/version/VersionChip';
|
import { VersionChip } from '@/components/version/VersionChip';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||||
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +25,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
function ProjectsPageContent() {
|
function ProjectsPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { overview, fetchOverview, createProject } = useProductStore();
|
const { overview, fetchOverview, createProject, deleteProject } = useProductStore();
|
||||||
const { requirements, fetchRequirements } = useRequirementStore();
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
const { plans, fetchPlans } = useVersionPlanStore();
|
const { plans, fetchPlans } = useVersionPlanStore();
|
||||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||||
@@ -121,7 +122,16 @@ function ProjectsPageContent() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
{paged.map((proj) => (
|
{paged.map((proj) => (
|
||||||
<ProjectRow key={proj.id} proj={proj} versionProgressMap={versionProgressMap} onClick={() => router.push(`/projects/${proj.id}`)} />
|
<ProjectRow
|
||||||
|
key={proj.id}
|
||||||
|
proj={proj}
|
||||||
|
versionProgressMap={versionProgressMap}
|
||||||
|
onClick={() => router.push(`/projects/${proj.id}`)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return;
|
||||||
|
deleteProject(proj.productId, proj.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||||
@@ -150,19 +160,24 @@ function ProjectRow({
|
|||||||
proj,
|
proj,
|
||||||
versionProgressMap,
|
versionProgressMap,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
proj: ProjectWithContext;
|
proj: ProjectWithContext;
|
||||||
versionProgressMap: Record<string, number>;
|
versionProgressMap: Record<string, number>;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const canDelete = useHasPermission('project:delete');
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
|
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
|
||||||
const completedCount = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) >= 100).length;
|
const completedCount = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) >= 100).length;
|
||||||
|
const versionCount = proj.versions.length;
|
||||||
|
const canActuallyDelete = versionCount === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0"
|
className="w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<FolderKanban size={20} className="shrink-0 text-[var(--ink-muted)]" />
|
<FolderKanban size={20} className="shrink-0 text-[var(--ink-muted)]" />
|
||||||
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
||||||
@@ -185,7 +200,38 @@ function ProjectRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{canDelete && (
|
||||||
|
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="p-1.5 rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
|
||||||
|
title="更多操作"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!canActuallyDelete) return;
|
||||||
|
setMenuOpen(false);
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={!canActuallyDelete}
|
||||||
|
title={canActuallyDelete ? '' : `项目下还有 ${versionCount} 个版本,不可删除`}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors ${canActuallyDelete ? 'text-red-600 hover:bg-red-50' : 'text-[var(--ink-muted)] cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface ProductState {
|
|||||||
createVersion: (productId: string, data: { name: string; status: string }) => void;
|
createVersion: (productId: string, data: { name: string; status: string }) => void;
|
||||||
updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void;
|
updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void;
|
||||||
deleteVersion: (productId: string, versionId: string) => void;
|
deleteVersion: (productId: string, versionId: string) => void;
|
||||||
|
deleteProject: (productId: string, projectId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProductStore = create<ProductState>((set, get) => ({
|
export const useProductStore = create<ProductState>((set, get) => ({
|
||||||
@@ -273,6 +274,19 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
set({ overview: updated });
|
set({ overview: updated });
|
||||||
saveLocal(updated);
|
saveLocal(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteProject: (productId, projectId) => {
|
||||||
|
const updated = get().overview.map((p) => {
|
||||||
|
if (p.id !== productId) return p;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
projects: p.projects.filter((proj) => proj.id !== projectId),
|
||||||
|
_count: { ...p._count, projects: Math.max(0, (p._count?.projects ?? 0) - 1) } as any,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
set({ overview: updated });
|
||||||
|
saveLocal(updated);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const STORAGE_KEY = 'ftb_products_overview_v4';
|
const STORAGE_KEY = 'ftb_products_overview_v4';
|
||||||
|
|||||||
102
docs/superpowers/specs/2026-06-16-project-delete-design.md
Normal file
102
docs/superpowers/specs/2026-06-16-project-delete-design.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 项目删除功能设计
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
项目模块当前没有删除入口。本期补齐"删除项目"功能,并对应权限点。
|
||||||
|
|
||||||
|
## 决策摘要
|
||||||
|
|
||||||
|
| 项 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 删除条件 | 项目下**有任何版本**均不可删(最严格,避免误删带数据的项目) |
|
||||||
|
| 按钮位置 | 项目行右侧三点菜单(hover 显示),菜单项"删除项目" |
|
||||||
|
| 不可删提示 | 菜单项 disabled + tooltip "项目下还有 N 个版本,不可删除" |
|
||||||
|
| 权限点 | `project:delete`(已存在于 `lib/permissions.ts`) |
|
||||||
|
| 默认能删的角色 | 仅超管 `role-admin`(PM/dev/test/design 都不含此权限) |
|
||||||
|
| 二次确认 | 浏览器原生 `confirm('确认删除项目「{name}」?')` |
|
||||||
|
| 卡片整体可点 | 保留——三点 icon `e.stopPropagation()` 避免冒泡 |
|
||||||
|
|
||||||
|
## 实施清单
|
||||||
|
|
||||||
|
**(1) `apps/web/stores/useProductStore.ts`** — 加 `deleteProject` action
|
||||||
|
|
||||||
|
```ts
|
||||||
|
deleteProject: (productId: string, projectId: string) => void;
|
||||||
|
|
||||||
|
// 实现:找到产品,从 projects 数组移除目标项目,写回 localStorage
|
||||||
|
deleteProject: (productId, projectId) => {
|
||||||
|
const overview = get().overview.map((p) =>
|
||||||
|
p.id === productId
|
||||||
|
? { ...p, projects: p.projects.filter((proj) => proj.id !== projectId) }
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
set({ overview });
|
||||||
|
saveLocal(overview);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**(2) `apps/web/app/projects/page.tsx`** — `ProjectRow` 加三点菜单
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const canDelete = useHasPermission('project:delete');
|
||||||
|
const versionCount = proj.versions.length;
|
||||||
|
const canActuallyDelete = canDelete && versionCount === 0;
|
||||||
|
|
||||||
|
// 行右侧
|
||||||
|
{canDelete && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<MenuItem
|
||||||
|
disabled={!canActuallyDelete}
|
||||||
|
title={versionCount > 0 ? `项目下还有 ${versionCount} 个版本,不可删除` : ''}
|
||||||
|
onClick={() => handleDelete(proj)}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
删除项目
|
||||||
|
</MenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
下拉菜单沿用 versions/page.tsx 已有的 dropdown 写法(`MoreHorizontal` icon + 绝对定位 + `fixed inset-0` overlay 关闭)。
|
||||||
|
|
||||||
|
**handleDelete**:
|
||||||
|
```tsx
|
||||||
|
const handleDelete = (proj: ProjectWithContext) => {
|
||||||
|
if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return;
|
||||||
|
deleteProject(proj.productId, proj.id);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**(3) 权限默认值不动**
|
||||||
|
|
||||||
|
- `lib/permissions.ts` 第 33 行 `std4('project')` 已包含 `project:delete`
|
||||||
|
- `DEFAULT_ROLE_PERMISSIONS`:超管 `['*']` 自动通过;PM 默认不含此权限(与"仅超管能删"一致)
|
||||||
|
- 无需修改
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
ProjectRow
|
||||||
|
│
|
||||||
|
├─ useHasPermission('project:delete') ─→ 决定三点菜单是否显示
|
||||||
|
│
|
||||||
|
└─ proj.versions.length ─→ 决定菜单项是否可点
|
||||||
|
│
|
||||||
|
└─ disabled + tooltip
|
||||||
|
│
|
||||||
|
点击「删除」 ─→ confirm ─→ useProductStore.deleteProject()
|
||||||
|
│
|
||||||
|
└─ 列表自动重渲(zustand 响应式)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `pnpm type-check` 0 错误
|
||||||
|
2. `pnpm build` 14+1 个页面成功
|
||||||
|
3. 登录陈十(超管 `13200132008/Ftb@2024`):
|
||||||
|
- hover 项目行右侧出现三点 icon
|
||||||
|
- 点开菜单看到"删除项目"
|
||||||
|
- 项目下有版本:菜单项灰色 + 鼠标悬停 tooltip "项目下还有 N 个版本,不可删除"
|
||||||
|
- 项目下无版本:菜单项可点 → confirm → 项目消失
|
||||||
|
4. 登录李四(开发 `13900139002/Ftb@2024`):项目行右侧无三点 icon(无 `project:delete` 权限)
|
||||||
|
5. 登录张三(PM `13800138001/Ftb@2024`):同上,无三点 icon
|
||||||
Reference in New Issue
Block a user