diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx
index 626633f..ff657c7 100644
--- a/apps/web/app/requirements/page.tsx
+++ b/apps/web/app/requirements/page.tsx
@@ -2,7 +2,7 @@
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
-import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
+import { Search, Plus, Lightbulb, ArrowUp, ArrowDown, ChevronDown, ChevronRight, FolderOpen, Layers } from 'lucide-react';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
@@ -10,7 +10,8 @@ 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 { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
+import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
+import { buildRequirementScopeTree, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
import { Pagination, usePagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
@@ -30,9 +31,8 @@ const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'pending_review', label: '待评审' },
{ key: 'adopted', label: '已采纳' },
{ key: 'rejected', label: '已拒绝' },
- { key: 'planned', label: '已规划' },
{ key: 'developing', label: '开发中' },
- { key: 'testing', label: '测试中' },
+ { key: 'dev_completed', label: '已完成' },
{ key: 'released', label: '已上线' },
{ key: 'closed', label: '已关闭' },
];
@@ -45,6 +45,117 @@ export default function RequirementsPage() {
);
}
+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,
@@ -60,9 +171,9 @@ function RequirementsPageContent() {
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
+ const [selectedScope, setSelectedScope] = useState({ type: 'all' });
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
- const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all');
@@ -79,6 +190,43 @@ function RequirementsPageContent() {
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
+ const selectedScopeKey = selectedScope.type === 'all'
+ ? 'all'
+ : selectedScope.type === 'product'
+ ? `product:${selectedScope.productId}`
+ : `project:${selectedScope.projectId}`;
+
+ useEffect(() => {
+ setVersionFilter('all');
+ }, [selectedScopeKey]);
+
+ const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
+ 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 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 '-';
@@ -90,7 +238,7 @@ function RequirementsPageContent() {
};
const filtered = useMemo(() => {
- let list = [...requirements];
+ let list = [...scopedRequirements];
// search filter
if (search) {
@@ -102,12 +250,9 @@ function RequirementsPageContent() {
// status filter
if (statusFilter !== 'all') {
- list = list.filter((r) => r.status === statusFilter);
- }
-
- // project filter
- if (projectFilter !== 'all') {
- list = list.filter((r) => r.projectId === projectFilter);
+ list = statusFilter === 'dev_completed'
+ ? list.filter((r) => deriveReqDevStatus(r.id, devTasks) === 'completed')
+ : list.filter((r) => r.status === statusFilter);
}
// priority filter
@@ -130,7 +275,7 @@ function RequirementsPageContent() {
});
return list;
- }, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, versionFilter, dateSort]);
+ }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -150,15 +295,53 @@ function RequirementsPageContent() {
};
return (
-
- {/* Header */}
-
-
-
需求
+
+
+
+
+ {/* Header */}
+
+
+
+
{selectedScopeTitle}
+
+ {scopedRequirements.length}
+
+
+
{selectedScopeMeta}
+
- {/* Project dropdown */}
- ({ value: p.id, label: p.name }))}
- placeholder="全部项目"
- allLabel="全部项目"
- />
-
{/* Priority dropdown */}
({ value: v.id, label: v.name }))}
+ options={scopedVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
@@ -253,8 +427,8 @@ function RequirementsPageContent() {
) : (
<>
-
-
+
+
| 需求编号 |
@@ -382,7 +556,7 @@ function RequirementsPageContent() {
>
)}
{/* 已采纳/已规划:关闭 */}
- {(req.status === 'adopted' || req.status === 'planned') && (
+ {(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (