'use client'; import { RouteGuard } from '@/components/auth/Guard'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Search, Plus, ArrowUp, ArrowDown, ChevronDown, ChevronRight, FolderOpen, Layers, Pencil, Check, XCircle, Archive, Trash2, } from 'lucide-react'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useProductStore } from '@/stores/useProductStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { flattenProjects, flattenVersions } from '@/lib/derive'; import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement'; import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement'; import { buildRequirementDevStatusMap, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine'; import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope'; import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort'; import { REQUIREMENT_TABLE_BADGE_CLASS, REQUIREMENT_TABLE_CLASS, REQUIREMENT_TABLE_COLUMN_WIDTHS, REQUIREMENT_TABLE_CONTAINER_CLASS, REQUIREMENT_TABLE_HEADER_CELL_CLASS, REQUIREMENT_TABLE_NOWRAP_CELL_CLASS, REQUIREMENT_TABLE_SMALL_BADGE_CLASS, getRequirementTableTextClass, } from '@/lib/requirement-table-layout'; import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link'; import { Pagination } from '@/components/Pagination'; import { RequirementModal } from '@/components/requirement/RequirementModal'; import { RequirementDetail } from '@/components/requirement/RequirementDetail'; import { FilterSelect } from '@/components/FilterSelect'; import { buildV22RequirementQuery } from '@/lib/requirement-v22-query'; import { loadV22RequirementsPage } from '@/lib/v22-api'; const PRIORITY_COLORS: Record = { P0: 'bg-red-500/10 text-red-600', P1: 'bg-orange-500/10 text-orange-600', P2: 'bg-blue-500/10 text-blue-600', P3: 'bg-zinc-100 text-zinc-600', P4: 'bg-zinc-100 text-zinc-500', }; const REQUIREMENT_ACTION_BUTTON_BASE_CLASS = 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-[var(--ink-muted)] transition-colors'; const REQUIREMENT_ACTION_BUTTON_TONE_CLASS = { neutral: 'hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]', success: 'text-emerald-600 hover:bg-emerald-50', danger: 'text-red-600 hover:bg-red-50', warning: 'text-orange-600 hover:bg-orange-50', } as const; const STATUS_TABS: { key: string; label: string }[] = [ { key: 'all', label: '全部' }, { key: 'pending_review', label: '待评审' }, { key: 'adopted', label: '已采纳' }, { key: 'rejected', label: '已拒绝' }, { key: 'developing', label: '开发中' }, { key: 'dev_completed', label: '已完成' }, { key: 'released', label: '已上线' }, { key: 'closed', label: '已关闭' }, ]; export default function RequirementsPage() { return ( ); } function RequirementProductNode({ product, selectedScope, onSelect, }: { product: RequirementProductScopeNode; selectedScope: RequirementScopeSelection; onSelect: (scope: RequirementScopeSelection) => void; }) { const [expanded, setExpanded] = useState(true); const active = selectedScope.type === 'product' && selectedScope.productId === product.id; return (
{expanded && (
{product.projects.map((project) => ( ))}
)}
); } function RequirementProjectNode({ project, selectedScope, onSelect, }: { project: RequirementProductScopeNode['projects'][number]; selectedScope: RequirementScopeSelection; onSelect: (scope: RequirementScopeSelection) => void; }) { const active = selectedScope.type === 'project' && selectedScope.projectId === project.id; return ( ); } function ScopeSignals({ count, compact = false }: { count: RequirementProductScopeNode['count']; compact?: boolean }) { const pendingReview = count.byStatus.pending_review ?? 0; const active = (count.byStatus.developing ?? 0) + (count.byStatus.testing ?? 0); return ( {!compact && } {!compact && } ); } function ScopeSignal({ title, label, count, tone }: { title: string; label: string; count: number; tone: 'amber' | 'blue' }) { if (count <= 0) return null; const toneClass = tone === 'amber' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'; return ( {label}{count} ); } function ScopeCount({ count }: { count: number }) { return ( {count} ); } function RequirementsPageContent() { const { requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement, sourceTargets, types, platforms, loaded: requirementsLoaded, } = useRequirementStore(); const { overview, fetchOverview } = useProductStore(); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); const user = useAuthStore((s) => s.user); const currentUserName = user?.name || '系统'; const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); const requirementDevStatusMap = useMemo(() => buildRequirementDevStatusMap(devTasks), [devTasks]); const devTasksByRequirement = useMemo(() => { const map = new Map(); for (const task of devTasks) { if (!task.requirementId) continue; const items = map.get(task.requirementId) ?? []; items.push(task); map.set(task.requirementId, items); } return map; }, [devTasks]); const [selectedScope, setSelectedScope] = useState({ type: 'all' }); const [scopeSearch, setScopeSearch] = useState(''); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [versionFilter, setVersionFilter] = useState('all'); const [showModal, setShowModal] = useState(false); const [editingReq, setEditingReq] = useState(null); const [viewingReq, setViewingReq] = useState(null); const [rejectingReq, setRejectingReq] = useState(null); const [rejectReason, setRejectReason] = useState(''); const [dateSort, setDateSort] = useState('desc'); const [page, setPage] = useState(1); const [pageSize, setPageSizeState] = useState(20); const [autoScopeSelected, setAutoScopeSelected] = useState(false); const [v22Requirements, setV22Requirements] = useState([]); const [v22NextCursor, setV22NextCursor] = useState(); const [v22CursorByPage, setV22CursorByPage] = useState>({ 1: undefined }); const [v22RequirementsLoading, setV22RequirementsLoading] = useState(false); const [v22RequirementsFailed, setV22RequirementsFailed] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); const selectedScopeKey = selectedScope.type === 'all' ? 'all' : selectedScope.type === 'product' ? `product:${selectedScope.productId}` : `project:${selectedScope.projectId}`; useEffect(() => { setVersionFilter('all'); }, [selectedScopeKey]); useEffect(() => { if (autoScopeSelected || selectedScope.type !== 'all') return; const firstProductId = overview[0]?.id; if (!firstProductId) return; setSelectedScope({ type: 'product', productId: firstProductId }); setAutoScopeSelected(true); }, [autoScopeSelected, overview, selectedScope.type]); const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]); const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]); const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]); const scopedVersions = useMemo(() => { if (selectedScope.type === 'product') return allVersions.filter((v) => v.productId === selectedScope.productId); if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId); return allVersions; }, [allVersions, selectedScope]); const hydrateRequirementStoreFallback = useCallback(async () => { const pendingLoads: Array> = []; if (selectedScope.type === 'product') { pendingLoads.push(fetchRequirements({ productId: selectedScope.productId })); } else if (selectedScope.type === 'project') { const project = allProjects.find((item) => item.id === selectedScope.projectId); if (project) pendingLoads.push(fetchRequirements({ productId: project.productId, projectId: selectedScope.projectId })); } else { for (const product of overview) { pendingLoads.push(fetchRequirements({ productId: product.id })); } } for (const version of scopedVersions) { pendingLoads.push(fetchDevTasks({ versionId: version.id })); } await Promise.all(pendingLoads); }, [allProjects, fetchDevTasks, fetchRequirements, overview, scopedVersions, selectedScope]); const filterResetKey = useMemo(() => JSON.stringify({ selectedScopeKey, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, }), [dateSort, priorityFilter, search, selectedScopeKey, statusFilter, typeFilter, versionFilter]); useEffect(() => { setPage(1); setV22CursorByPage({ 1: undefined }); setV22Requirements([]); setV22NextCursor(undefined); }, [filterResetKey]); const v22Cursor = v22CursorByPage[page]; const v22RequirementQuery = useMemo(() => buildV22RequirementQuery({ selectedScope, projects: allProjects.map((project) => ({ id: project.id, productId: project.productId })), statusFilter, priorityFilter, typeFilter, versionFilter, search, dateSort, limit: pageSize, cursor: v22Cursor, }), [ allProjects, dateSort, pageSize, priorityFilter, search, selectedScope, statusFilter, typeFilter, versionFilter, v22Cursor, ]); useEffect(() => { if (v22RequirementQuery && !v22RequirementsFailed) return; void hydrateRequirementStoreFallback(); }, [hydrateRequirementStoreFallback, v22RequirementQuery, v22RequirementsFailed]); useEffect(() => { setV22RequirementsFailed(false); }, [filterResetKey, pageSize]); useEffect(() => { if (!v22RequirementQuery) { setV22Requirements([]); setV22NextCursor(undefined); return; } let cancelled = false; setV22RequirementsLoading(true); loadV22RequirementsPage(v22RequirementQuery) .then((result) => { if (cancelled) return; setV22Requirements(result.items); setV22NextCursor(result.nextCursor); if (result.nextCursor) { setV22CursorByPage((current) => ( current[page + 1] === result.nextCursor ? current : { ...current, [page + 1]: result.nextCursor } )); } }) .catch(() => { if (cancelled) return; setV22Requirements([]); setV22NextCursor(undefined); setV22RequirementsFailed(true); void hydrateRequirementStoreFallback(); }) .finally(() => { if (!cancelled) setV22RequirementsLoading(false); }); return () => { cancelled = true; }; }, [hydrateRequirementStoreFallback, page, v22RequirementQuery]); const selectedScopeTitle = useMemo(() => { if (selectedScope.type === 'product') { return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求'; } if (selectedScope.type === 'project') { return allProjects.find((project) => project.id === selectedScope.projectId)?.name ?? '项目需求'; } return '全部需求'; }, [allProjects, overview, selectedScope]); const selectedScopeMeta = useMemo(() => { if (selectedScope.type === 'product') return '产品下全部项目'; if (selectedScope.type === 'project') { const project = allProjects.find((item) => item.id === selectedScope.projectId); return project ? `${project.productName} / 项目` : '项目'; } return '所有产品与项目'; }, [allProjects, selectedScope]); // Resolve version name from overview const resolveVersionName = (versionId?: string): string => { if (!versionId) return '-'; for (const product of overview) { const version = product.versions?.find((v: any) => v.id === versionId); if (version) return version.name; } return '-'; }; const filtered = useMemo(() => { let list = [...scopedRequirements]; // search filter if (search) { const q = search.toLowerCase(); list = list.filter( (r) => r.code.toLowerCase().includes(q) || r.title.toLowerCase().includes(q), ); } // status filter if (statusFilter !== 'all') { list = statusFilter === 'dev_completed' ? list.filter((r) => requirementDevStatusMap.get(r.id) === 'completed') : list.filter((r) => r.status === statusFilter); } // priority filter if (priorityFilter !== 'all') { list = list.filter((r) => r.priority === priorityFilter); } // type filter if (typeFilter !== 'all') { list = list.filter((r) => r.typeId === typeFilter); } if (versionFilter !== 'all') { list = list.filter((r) => r.versionId === versionFilter); } list = sortRequirementsByCreatedAt(list, dateSort); return list; }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]); const appDataRequirementById = useMemo(() => new Map(requirements.map((item) => [item.id, item])), [requirements]); const usingV22Requirements = Boolean(v22RequirementQuery) && !v22RequirementsFailed; const appTotalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const appPage = Math.min(page, appTotalPages); const appPaged = filtered.slice((appPage - 1) * pageSize, appPage * pageSize); const displayedRequirements = useMemo(() => { if (!usingV22Requirements) return appPaged; return v22Requirements.map((req) => { if (req.creator) return req; const appDataRequirement = appDataRequirementById.get(req.id); return appDataRequirement?.creator ? { ...req, creator: appDataRequirement.creator } : req; }); }, [appDataRequirementById, appPaged, usingV22Requirements, v22Requirements]); const total = usingV22Requirements ? (page - 1) * pageSize + v22Requirements.length + (v22NextCursor ? 1 : 0) : filtered.length; const displayPage = usingV22Requirements ? page : appPage; const displayedScopeCount = usingV22Requirements ? total : scopedRequirements.length; const canMutateRequirement = (req: Requirement) => Boolean(req.productId); const handlePageChange = (nextPage: number) => { if (usingV22Requirements && nextPage > page && !v22CursorByPage[nextPage]) return; setPage(nextPage); }; const setPageSize = (size: number) => { setPageSizeState(size); setPage(1); setV22CursorByPage({ 1: undefined }); }; const handleEdit = (req: Requirement) => { if (!canMutateRequirement(req)) return; setViewingReq(null); setEditingReq(req); setShowModal(true); }; const handleRowClick = (req: Requirement) => { setViewingReq(req); }; const handleCreate = async () => { await hydrateRequirementStoreFallback(); setEditingReq(null); setShowModal(true); }; const handleSelectScope = (scope: RequirementScopeSelection) => { setAutoScopeSelected(true); setSelectedScope(scope); }; return (
{/* Header */}

{selectedScopeTitle}

{displayedScopeCount}

{selectedScopeMeta}

{/* Filters */}
setSearch(e.target.value)} placeholder="搜索编号或标题" className="h-8 w-64 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)]" />
{/* Status tabs */}
{STATUS_TABS.map((tab) => ( ))}
{/* Priority dropdown */} ({ value: p, label: p }))} placeholder="全部优先级" allLabel="全部优先级" /> {/* Type dropdown */} ({ value: t.id, label: t.name }))} placeholder="全部类型" allLabel="全部类型" /> {/* Version dropdown */} ({ value: v.id, label: v.name }))} placeholder="全部版本" allLabel="全部版本" />
{/* Content */}
{displayedRequirements.length === 0 && !v22RequirementsLoading ? (

没有找到匹配的需求

尝试调整筛选条件

) : ( <>
{REQUIREMENT_TABLE_COLUMN_WIDTHS.map((width, index) => ( ))} {displayedRequirements.map((req) => ( handleRowClick(req)} className="border-b border-[var(--line-soft)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--bg-subtle)]" > {/* 需求编号 */} {/* 需求概述 */} {/* 需求来源 */} {/* 所属项目 */} {/* 需求类型 */} {/* 状态 */} {/* 优先级 */} {/* 所属版本 */} {/* 开发状态 */} {/* 录入人员 */} {/* 录入日期 */} {/* 操作 */} ))}
需求编号 需求概述 需求来源 所属项目 需求类型 业务状态 优先级 所属版本 实际进度 录入人员 setDateSort(dateSort === 'desc' ? 'asc' : 'desc')} > 录入日期 {dateSort === 'desc' ? : } 操作
{req.code} {req.title} {(() => { const sourceText = `${SOURCE_TYPE_LABEL[req.sourceType]}: ${req.sourceTarget}`; return ( {sourceText} ); })()} {(() => { const projectName = allProjects.find((p) => p.id === req.projectId)?.name ?? '-'; return ( {projectName} ); })()} {(() => { const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-'; return ( {typeName} ); })()} {REQ_STATUS_LABEL[req.status]} {req.priority} {(() => { const versionName = resolveVersionName(req.versionId); return ( {versionName} ); })()} {(() => { const devStatus = requirementDevStatusMap.get(req.id) ?? 'unscheduled'; return ( {REQ_DEV_STATUS_LABEL[devStatus]} ); })()} {req.creator} {new Date(req.createdAt).toISOString().slice(0, 10)} e.stopPropagation()}>
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */} {canMutateRequirement(req) && (['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && ( )} {/* 待评审:采纳/拒绝 */} {canMutateRequirement(req) && req.status === 'pending_review' && ( <> )} {/* 已采纳/已规划:关闭 */} {canMutateRequirement(req) && (req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && ( )} {/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */} {canMutateRequirement(req) && (['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && ( )}
)}
{/* Detail drawer */} {viewingReq && ( ({ id: p.id, name: p.name }))} types={types} platforms={platforms} sourceTargets={sourceTargets} requirements={requirements} resolveVersionName={resolveVersionName} onClose={() => setViewingReq(null)} onEdit={canMutateRequirement(viewingReq) && canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined} onAdopt={canMutateRequirement(viewingReq) ? () => { updateRequirement(viewingReq.id, { productId: viewingReq.productId, status: 'adopted' }); setViewingReq(null); } : undefined} onReject={canMutateRequirement(viewingReq) ? () => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); } : undefined} onCloseReq={canMutateRequirement(viewingReq) && canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { productId: viewingReq.productId, status: 'closed' }); setViewingReq(null); } : undefined} onDelete={canMutateRequirement(viewingReq) ? () => { deleteRequirement(viewingReq.id, viewingReq.productId); setViewingReq(null); } : undefined} /> )} {/* Modal */} {showModal && ( ({ id: p.id, name: p.name }))} projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))} versions={allVersions.map((v) => ({ id: v.id, name: v.name, projectId: v.projectId }))} sourceTargets={sourceTargets} types={types} platforms={platforms} requirements={requirements} currentUserName={currentUserName} onClose={() => { setShowModal(false); setEditingReq(null); }} onSubmit={(data) => { if (editingReq) { const versionPatch = getRequirementVersionSelectionPatch( editingReq, data.versionId, data.versionId !== editingReq.versionId ? currentUserName : editingReq.addedToVersionBy, ); updateRequirement(editingReq.id, { ...data, productId: editingReq.productId, ...versionPatch }); } else { createRequirement(data); } setShowModal(false); setEditingReq(null); }} /> )} {/* 拒绝原因弹窗 */} {rejectingReq && (
setRejectingReq(null)}>
e.stopPropagation()}>

拒绝需求

需求:{rejectingReq.title}