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) && ( - {/* Actions */} -
- {canEdit && ( - - )} - {onAdopt && req.status === 'pending_review' && ( - - )} - {onReject && req.status === 'pending_review' && ( - - )} - {onCloseReq && (req.status === 'adopted' || req.status === 'planned') && ( - - )} - {onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && ( - - )} +
+ {canEdit && 编辑} + {onAdopt && req.status === 'pending_review' && 采纳} + {onReject && req.status === 'pending_review' && 拒绝} + {onCloseReq && (req.status === 'adopted' || req.status === 'planned') && 关闭} + {onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && 删除}
- {/* Content */} -
- {/* 需求概述 */} -
-

{req.title}

-
- - {/* 需求描述 */} - {req.description && ( -
- -

{req.description}

+
+
+

{req.title}

+
+ {contextItems.map((item) => {item})}
- )} +
- {/* 基本信息 */} -
- - - - - - - {req.priority} - - - {req.effort && ( - - - {EFFORT_SHORT[req.effort]} - - + + {req.description ? ( +

{req.description}

+ ) : ( +

暂无描述

)} - -
+ - {/* 人员 & 日期 */} -
- - - -
- - {/* 关联父需求 */} - {req.parentId && ( -
- + +
+ + + + + +
- )} +
+ + +
+ + + + +
+
); } -function Label({ children }: { children: React.ReactNode }) { - return
{children}
; +function DetailSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); } -function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) { +function InfoItem({ label, value }: { label: string; value: string }) { return ( -
- {label} - {children ?? {value}} +
+
{label}
+
{value}
); } + +function StatusBadge({ className, children }: { className: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ContextPill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ActionButton({ + children, + onClick, + tone = 'neutral', +}: { + children: React.ReactNode; + onClick?: () => void; + tone?: 'neutral' | 'success' | 'warning' | 'danger'; +}) { + const toneClass = { + neutral: 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-card)]', + success: 'border-emerald-200 text-emerald-700 hover:bg-emerald-50', + warning: 'border-orange-200 text-orange-700 hover:bg-orange-50', + danger: 'border-red-200 text-red-600 hover:bg-red-50', + }[tone]; + + return ( + + ); +} diff --git a/apps/web/components/requirement/RequirementModal.tsx b/apps/web/components/requirement/RequirementModal.tsx index d54d993..1f189b7 100644 --- a/apps/web/components/requirement/RequirementModal.tsx +++ b/apps/web/components/requirement/RequirementModal.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; import { X } from 'lucide-react'; -import type { Requirement, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; +import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Priority } from '@/lib/derive'; -import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement'; +import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement'; interface RequirementModalProps { open: boolean; @@ -22,7 +22,6 @@ interface RequirementModalProps { } const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4']; -const EFFORTS: Effort[] = ['S', 'M', 'L', 'XL']; const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management']; const PRIORITY_COLORS: Record = { @@ -57,7 +56,6 @@ export function RequirementModal({ const [selectedPlatforms, setSelectedPlatforms] = useState([]); const [typeId, setTypeId] = useState(''); const [priority, setPriority] = useState('P2'); - const [effort, setEffort] = useState('M'); const [productOwner, setProductOwner] = useState(''); const [parentId, setParentId] = useState(''); const [versionId, setVersionId] = useState(''); @@ -86,7 +84,6 @@ export function RequirementModal({ setSelectedPlatforms(initial.platforms || []); setTypeId(initial.typeId || ''); setPriority(initial.priority || 'P2'); - setEffort(initial.effort || 'M'); setProductOwner(initial.productOwner || ''); setParentId(initial.parentId || ''); } else { @@ -100,7 +97,6 @@ export function RequirementModal({ setSelectedPlatforms([]); setTypeId(''); setPriority('P2'); - setEffort('M'); setProductOwner(''); setParentId(''); } @@ -129,7 +125,7 @@ export function RequirementModal({ platforms: selectedPlatforms, typeId: typeId || undefined, priority, - effort, + effort: initial?.effort ?? 'M', status: initial?.status ?? 'pending_review', creator: initial?.creator || currentUserName || '系统', parentId: parentId || undefined, diff --git a/apps/web/lib/linkage-engine.test.ts b/apps/web/lib/linkage-engine.test.ts new file mode 100644 index 0000000..10c0bdc --- /dev/null +++ b/apps/web/lib/linkage-engine.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { DevTask } from './dev-task'; +import { canEditRequirement } from './linkage-engine'; +import * as linkageEngine from './linkage-engine'; + +function task(patch: Partial = {}): DevTask { + return { + id: 'task-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: 'Implement requirement', + categoryId: 'cat-frontend', + assigneeId: 'Alice', + priority: 'P2', + expectedStartAt: '2026-06-25T01:00:00.000Z', + expectedEndAt: '2026-06-25T02:00:00.000Z', + status: 'todo', + isBlocked: false, + createdBy: 'PM', + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + ...patch, + }; +} + +test('completed requirement development status cannot be edited', () => { + assert.equal(canEditRequirement('req-1', [task({ status: 'submitted' })]), false); +}); + +test('active or completed requirement development status cannot be closed', () => { + const canCloseRequirement = (linkageEngine as any).canCloseRequirement; + + assert.equal(typeof canCloseRequirement, 'function'); + assert.equal(canCloseRequirement('req-1', [task({ status: 'in_progress' })]), false); + assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false); + assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false); +}); diff --git a/apps/web/lib/linkage-engine.ts b/apps/web/lib/linkage-engine.ts index b59b182..5dbd8d8 100644 --- a/apps/web/lib/linkage-engine.ts +++ b/apps/web/lib/linkage-engine.ts @@ -34,7 +34,16 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt */ export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean { const status = deriveReqDevStatus(reqId, devTasks); - return status !== 'developing'; + return !isRequirementExecutionLocked(status); +} + +export function canCloseRequirement(reqId: string, devTasks: DevTask[]): boolean { + const status = deriveReqDevStatus(reqId, devTasks); + return !isRequirementExecutionLocked(status); +} + +function isRequirementExecutionLocked(status: ReqDevStatus): boolean { + return status === 'developing' || status === 'completed'; } /** diff --git a/apps/web/lib/requirement-scope.test.ts b/apps/web/lib/requirement-scope.test.ts new file mode 100644 index 0000000..b9deef5 --- /dev/null +++ b/apps/web/lib/requirement-scope.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Requirement } from './requirement'; +import { + buildRequirementScopeTree, + filterRequirementsByScope, + type RequirementScopeSelection, +} from './requirement-scope'; + +const baseRequirement = { + code: 'REQ-001', + title: 'Requirement', + description: '', + sourceType: 'internal', + sourceTarget: '', + platforms: [] as string[], + typeId: '', + priority: 'P2', + effort: 'M', + creator: 'tester', + createdAt: '2026-06-29T00:00:00.000Z', +}; + +function req(id: string, productId: string, projectId: string, status: Requirement['status'] = 'pending_review'): Requirement { + return { + ...baseRequirement, + id, + code: id.toUpperCase(), + productId, + projectId, + status, + } as Requirement; +} + +const overview = [ + { + id: 'product-1', + name: 'CRM', + projects: [ + { id: 'project-1', name: 'Mobile', description: '', createdAt: '2026-06-29T00:00:00.000Z' }, + { id: 'project-2', name: 'Admin', description: '', createdAt: '2026-06-29T00:00:00.000Z' }, + ], + versions: [], + }, + { + id: 'product-2', + name: 'BI', + projects: [ + { id: 'project-3', name: 'Dashboard', description: '', createdAt: '2026-06-29T00:00:00.000Z' }, + ], + versions: [], + }, +]; + +test('filters requirements by all product and project scopes', () => { + const requirements = [ + req('req-1', 'product-1', 'project-1'), + req('req-2', 'product-1', 'project-2'), + req('req-3', 'product-2', 'project-3'), + ]; + + const allScope: RequirementScopeSelection = { type: 'all' }; + const productScope: RequirementScopeSelection = { type: 'product', productId: 'product-1' }; + const projectScope: RequirementScopeSelection = { type: 'project', projectId: 'project-2' }; + + assert.deepEqual(filterRequirementsByScope(requirements, allScope).map((item) => item.id), ['req-1', 'req-2', 'req-3']); + assert.deepEqual(filterRequirementsByScope(requirements, productScope).map((item) => item.id), ['req-1', 'req-2']); + assert.deepEqual(filterRequirementsByScope(requirements, projectScope).map((item) => item.id), ['req-2']); +}); + +test('builds product and project tree counts from requirements', () => { + const tree = buildRequirementScopeTree(overview, [ + req('req-1', 'product-1', 'project-1', 'pending_review'), + req('req-2', 'product-1', 'project-1', 'developing'), + req('req-3', 'product-1', 'project-2', 'closed'), + req('req-4', 'product-2', 'project-3', 'adopted'), + ]); + + assert.equal(tree[0].count.total, 3); + assert.equal(tree[0].count.byStatus.pending_review, 1); + assert.equal(tree[0].projects[0].count.total, 2); + assert.equal(tree[0].projects[0].count.byStatus.developing, 1); + assert.equal(tree[0].projects[1].count.total, 1); + assert.equal(tree[1].count.total, 1); +}); diff --git a/apps/web/lib/requirement-scope.ts b/apps/web/lib/requirement-scope.ts new file mode 100644 index 0000000..962e61d --- /dev/null +++ b/apps/web/lib/requirement-scope.ts @@ -0,0 +1,70 @@ +import type { Requirement, RequirementStatus } from './requirement'; + +export type RequirementScopeSelection = + | { type: 'all' } + | { type: 'product'; productId: string } + | { type: 'project'; projectId: string }; + +export interface RequirementScopeCount { + total: number; + byStatus: Partial>; +} + +export interface RequirementProjectScopeNode { + id: string; + name: string; + count: RequirementScopeCount; +} + +export interface RequirementProductScopeNode { + id: string; + name: string; + count: RequirementScopeCount; + projects: RequirementProjectScopeNode[]; +} + +export function filterRequirementsByScope( + requirements: Requirement[], + scope: RequirementScopeSelection, +): Requirement[] { + if (scope.type === 'product') { + return requirements.filter((requirement) => requirement.productId === scope.productId); + } + if (scope.type === 'project') { + return requirements.filter((requirement) => requirement.projectId === scope.projectId); + } + return requirements; +} + +export function buildRequirementScopeTree( + overview: { id: string; name: string; projects: { id: string; name: string }[] }[], + requirements: Requirement[], +): RequirementProductScopeNode[] { + return overview.map((product) => { + const productRequirements = requirements.filter((requirement) => requirement.productId === product.id); + return { + id: product.id, + name: product.name, + count: countRequirements(productRequirements), + projects: product.projects.map((project) => { + const projectRequirements = requirements.filter((requirement) => requirement.projectId === project.id); + return { + id: project.id, + name: project.name, + count: countRequirements(projectRequirements), + }; + }), + }; + }); +} + +function countRequirements(requirements: Requirement[]): RequirementScopeCount { + const byStatus: Partial> = {}; + for (const requirement of requirements) { + byStatus[requirement.status] = (byStatus[requirement.status] ?? 0) + 1; + } + return { + total: requirements.length, + byStatus, + }; +}
需求编号