'use client'; import { RouteGuard } from '@/components/auth/Guard'; import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2, Pencil } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { flattenProjects, flattenVersions, ProjectWithContext } from '@/lib/derive'; 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 ( ); } function ProjectsPageContent() { const router = useRouter(); const { overview, fetchOverview, createProject, updateProject, deleteProject } = useProductStore(); const { requirements, fetchRequirements } = useRequirementStore(); const { plans, fetchPlans } = useVersionPlanStore(); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); const { testCases, fetchTestCases } = useTestCaseStore(); const [search, setSearch] = useState(''); const [productFilter, setProductFilter] = useState('all'); const [showForm, setShowForm] = useState(false); const [editingProject, setEditingProject] = useState(null); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchPlans(); }, [fetchPlans]); useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); const versionProgressMap = useMemo( () => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases), [allVersions, plans, requirements, devTasks, testCases], ); const filtered = useMemo(() => { return allProjects.filter((p) => { if (productFilter !== 'all' && p.productId !== productFilter) return false; if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false; return true; }); }, [allProjects, search, productFilter]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); const productOptions = useMemo( () => overview.map((p) => ({ id: p.id, name: p.name })), [overview] ); const activeProductName = productFilter === 'all' ? '全部产品' : productOptions.find((p) => p.id === productFilter)?.name || '全部产品'; return ( {/* Header */} 项目 {allProjects.length} setShowForm(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)]" > 新建项目 {/* Filters */} setSearch(e.target.value)} placeholder="搜索项目名称…" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" /> {/* List */} {filtered.length === 0 ? ( ) : ( {paged.map((proj) => ( router.push(`/projects/${proj.id}`)} onEdit={() => setEditingProject(proj)} onDelete={() => { if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return; deleteProject(proj.productId, proj.id); }} /> ))} )} {showForm && ( setShowForm(false)} onSubmit={async (productId, name, description) => { await createProject(productId, { name, description }); setShowForm(false); }} /> )} {editingProject && ( setEditingProject(null)} onSubmit={async (name) => { updateProject(editingProject.productId, editingProject.id, { name }); setEditingProject(null); }} /> )} ); } /* ─── ProjectRow ─────────────────────────────────────────────────── */ function ProjectRow({ proj, versionProgressMap, onClick, onEdit, onDelete, }: { proj: ProjectWithContext; versionProgressMap: Record; onClick: () => void; onEdit: () => void; onDelete: () => void; }) { const canEdit = useHasPermission('project:edit'); 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 ( {proj.name} {proj.productName} {proj.description} {activeVersions.map((v) => ( ))} {completedCount > 0 && ( +{completedCount} 已完成 )} {(canEdit || canDelete) && ( e.stopPropagation()}> setMenuOpen(!menuOpen)} className="p-1.5 rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]" title="更多操作" > {menuOpen && ( <> setMenuOpen(false)} /> {canEdit && ( { setMenuOpen(false); onEdit(); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-hover)]" > 编辑项目 )} {canDelete && ( { 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'}`} > 删除项目 )} > )} )} ); } /* ─── ProductDropdown ────────────────────────────────────────────── */ function ProductDropdown({ value, options, onChange, activeName, }: { value: string; options: { id: string; name: string }[]; onChange: (v: string) => void; activeName: string; }) { const [open, setOpen] = useState(false); return ( setOpen(!open)} className={`flex h-8 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors ${ value !== 'all' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg)] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]' }`} > {activeName} {open && ( <> setOpen(false)} /> { onChange('all'); setOpen(false); }} label="全部产品" /> {options.map((opt) => ( { onChange(opt.id); setOpen(false); }} label={opt.name} /> ))} > )} ); } function DropdownItem({ active, onClick, label, }: { active: boolean; onClick: () => void; label: string; }) { return ( {label} ); } /* ─── LegendDot ──────────────────────────────────────────────────── */ function LegendDot({ color, label }: { color: string; label: string }) { return ( {label} ); } /* ─── EmptyState ─────────────────────────────────────────────────── */ function EmptyState() { return ( 暂无匹配项目 尝试调整搜索条件或筛选器 ); } /* ─── EditProjectModal ───────────────────────────────────────────── */ function EditProjectModal({ project, overview, onClose, onSubmit, }: { project: ProjectWithContext; overview: any[]; onClose: () => void; onSubmit: (name: string) => Promise; }) { const [name, setName] = useState(project.name); const [submitting, setSubmitting] = useState(false); const [duplicateError, setDuplicateError] = useState(false); const handleSubmit = async () => { const nextName = name.trim(); if (!nextName) return; const product = overview.find((p: any) => p.id === project.productId); const exists = product?.projects?.some( (proj: any) => proj.id !== project.id && proj.name === nextName, ); if (exists) { setDuplicateError(true); return; } setSubmitting(true); try { await onSubmit(nextName); } finally { setSubmitting(false); } }; return ( 编辑项目 项目名称 * { setName(e.target.value); setDuplicateError(false); }} className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${ duplicateError ? 'border-red-500 focus:border-red-500 focus:ring-red-100' : 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]' }`} /> {duplicateError && ( 该产品下已存在同名项目 )} 取消 {submitting ? '保存中…' : '保存'} ); } /* ─── CreateProjectModal ─────────────────────────────────────────── */ function CreateProjectModal({ products, overview, onClose, onSubmit, }: { products: { id: string; name: string }[]; overview: any[]; onClose: () => void; onSubmit: (productId: string, name: string, description: string) => Promise; }) { const [productId, setProductId] = useState(''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [submitting, setSubmitting] = useState(false); const [duplicateError, setDuplicateError] = useState(false); const handleSubmit = async () => { if (!productId || !name.trim()) return; const product = overview.find((p: any) => p.id === productId); if (product) { const exists = product.projects?.some( (proj: any) => proj.name === name.trim() ); if (exists) { setDuplicateError(true); return; } } setSubmitting(true); try { await onSubmit(productId, name.trim(), description.trim()); } finally { setSubmitting(false); } }; return ( 新建项目 {/* Product select */} 产品 * setProductId(e.target.value)} className="h-9 w-full appearance-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 pr-8 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" > 请选择产品 {products.map((p) => ( {p.name} ))} {/* Name */} 项目名称 * { setName(e.target.value); setDuplicateError(false); }} placeholder="请输入项目名称" className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${ duplicateError ? 'border-red-500 focus:border-red-500 focus:ring-red-100' : 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]' }`} /> {duplicateError && ( 该产品下已存在同名项目 )} {/* Description */} 描述 setDescription(e.target.value)} placeholder="项目描述(可选)" rows={3} className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" /> 取消 {submitting ? '提交中…' : '创建'} ); }
暂无匹配项目
尝试调整搜索条件或筛选器
该产品下已存在同名项目