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:
Script Generator
2026-06-17 11:11:19 +08:00
parent 53ae3f02a5
commit cad190d83b
3 changed files with 169 additions and 7 deletions

View File

@@ -3,7 +3,7 @@
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
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 { useRequirementStore } from '@/stores/useRequirementStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
@@ -13,6 +13,7 @@ import { flattenProjects, flattenVersions, ProjectWithContext } from '@/lib/deri
import { VersionChip } from '@/components/version/VersionChip';
import { Pagination, usePagination } from '@/components/Pagination';
import { buildVersionProgressMap } from '@/lib/version-progress';
import { useHasPermission } from '@/components/auth/Guard';
export default function ProjectsPage() {
return (
@@ -24,7 +25,7 @@ export default function ProjectsPage() {
function ProjectsPageContent() {
const router = useRouter();
const { overview, fetchOverview, createProject } = useProductStore();
const { overview, fetchOverview, createProject, deleteProject } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
@@ -121,7 +122,16 @@ function ProjectsPageContent() {
<div>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
{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>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
@@ -150,19 +160,24 @@ function ProjectRow({
proj,
versionProgressMap,
onClick,
onDelete,
}: {
proj: ProjectWithContext;
versionProgressMap: Record<string, number>;
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 completedCount = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) >= 100).length;
const versionCount = proj.versions.length;
const canActuallyDelete = versionCount === 0;
return (
<button
type="button"
<div
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)]" />
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
@@ -185,7 +200,38 @@ function ProjectRow({
</span>
)}
</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>
);
}

View File

@@ -79,6 +79,7 @@ interface ProductState {
createVersion: (productId: string, data: { name: string; status: string }) => void;
updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void;
deleteVersion: (productId: string, versionId: string) => void;
deleteProject: (productId: string, projectId: string) => void;
}
export const useProductStore = create<ProductState>((set, get) => ({
@@ -273,6 +274,19 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: 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';