feat(需求池): 调整需求池布局与详情展示

This commit is contained in:
Script Generator
2026-06-29 15:07:07 +08:00
parent aa6970876a
commit 8a1726c426
7 changed files with 530 additions and 136 deletions

View File

@@ -2,7 +2,7 @@
import { RouteGuard } from '@/components/auth/Guard'; import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react'; 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 { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
@@ -10,7 +10,8 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { flattenProjects, flattenVersions } from '@/lib/derive'; import { flattenProjects, flattenVersions } from '@/lib/derive';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement'; import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
import type { Requirement, RequirementStatus, SourceType } 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 { Pagination, usePagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal'; import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail'; import { RequirementDetail } from '@/components/requirement/RequirementDetail';
@@ -30,9 +31,8 @@ const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'pending_review', label: '待评审' }, { key: 'pending_review', label: '待评审' },
{ key: 'adopted', label: '已采纳' }, { key: 'adopted', label: '已采纳' },
{ key: 'rejected', label: '已拒绝' }, { key: 'rejected', label: '已拒绝' },
{ key: 'planned', label: '已规划' },
{ key: 'developing', label: '开发中' }, { key: 'developing', label: '开发中' },
{ key: 'testing', label: '测试中' }, { key: 'dev_completed', label: '已完成' },
{ key: 'released', label: '已上线' }, { key: 'released', label: '已上线' },
{ key: 'closed', 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 (
<div className="mb-0.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex h-7 w-6 shrink-0 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
aria-label={expanded ? '收起产品' : '展开产品'}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => onSelect({ type: 'product', productId: product.id })}
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
<span className="min-w-0 flex-1 truncate text-left">{product.name}</span>
<ScopeSignals count={product.count} />
</button>
</div>
{expanded && (
<div className="ml-7 mt-0.5 space-y-0.5">
{product.projects.map((project) => (
<RequirementProjectNode
key={project.id}
project={project}
selectedScope={selectedScope}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
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 (
<button
type="button"
onClick={() => onSelect({ type: 'project', projectId: project.id })}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-[var(--ink-muted)]" />
<span className="min-w-0 flex-1 truncate text-left">{project.name}</span>
<ScopeSignals count={project.count} compact />
</button>
);
}
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 (
<span className="flex shrink-0 items-center gap-1">
{!compact && <ScopeSignal title="待评审" label="评" count={pendingReview} tone="amber" />}
{!compact && <ScopeSignal title="执行中" label="进" count={active} tone="blue" />}
<ScopeCount count={count.total} />
</span>
);
}
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 (
<span title={title} className={`rounded px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${toneClass}`}>
{label}{count}
</span>
);
}
function ScopeCount({ count }: { count: number }) {
return (
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)]">
{count}
</span>
);
}
function RequirementsPageContent() { function RequirementsPageContent() {
const { const {
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement, requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
@@ -60,9 +171,9 @@ function RequirementsPageContent() {
const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all'); const [versionFilter, setVersionFilter] = useState('all');
@@ -79,6 +190,43 @@ function RequirementsPageContent() {
useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); 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 // Resolve version name from overview
const resolveVersionName = (versionId?: string): string => { const resolveVersionName = (versionId?: string): string => {
if (!versionId) return '-'; if (!versionId) return '-';
@@ -90,7 +238,7 @@ function RequirementsPageContent() {
}; };
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = [...requirements]; let list = [...scopedRequirements];
// search filter // search filter
if (search) { if (search) {
@@ -102,12 +250,9 @@ function RequirementsPageContent() {
// status filter // status filter
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
list = list.filter((r) => r.status === statusFilter); list = statusFilter === 'dev_completed'
} ? list.filter((r) => deriveReqDevStatus(r.id, devTasks) === 'completed')
: list.filter((r) => r.status === statusFilter);
// project filter
if (projectFilter !== 'all') {
list = list.filter((r) => r.projectId === projectFilter);
} }
// priority filter // priority filter
@@ -130,7 +275,7 @@ function RequirementsPageContent() {
}); });
return list; 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); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -150,15 +295,53 @@ function RequirementsPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full overflow-hidden bg-[var(--bg)]">
{/* Header */} <aside className="flex w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5"> <div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-4">
<div className="flex items-center gap-2.5"> <div>
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1> <h1 className="text-[14px] font-semibold text-[var(--ink)]"></h1>
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]"> / </p>
</div>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]"> <span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
{requirements.length} {requirements.length}
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto p-2">
<button
onClick={() => setSelectedScope({ type: 'all' })}
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${
selectedScope.type === 'all'
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
<Layers className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 text-left"></span>
<ScopeCount count={requirements.length} />
</button>
{scopeTree.map((product) => (
<RequirementProductNode
key={product.id}
product={product}
selectedScope={selectedScope}
onSelect={setSelectedScope}
/>
))}
</div>
</aside>
<main className="min-w-0 flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="min-w-0">
<div className="flex items-center gap-2.5">
<h2 className="truncate text-[15px] font-semibold tracking-tight text-[var(--ink)]">{selectedScopeTitle}</h2>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
{scopedRequirements.length}
</span>
</div>
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setDrawerType('source')} onClick={() => setDrawerType('source')}
@@ -206,15 +389,6 @@ function RequirementsPageContent() {
))} ))}
</div> </div>
{/* Project dropdown */}
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
placeholder="全部项目"
allLabel="全部项目"
/>
{/* Priority dropdown */} {/* Priority dropdown */}
<FilterSelect <FilterSelect
value={priorityFilter} value={priorityFilter}
@@ -237,7 +411,7 @@ function RequirementsPageContent() {
<FilterSelect <FilterSelect
value={versionFilter} value={versionFilter}
onChange={setVersionFilter} onChange={setVersionFilter}
options={allVersions.map((v) => ({ value: v.id, label: v.name }))} options={scopedVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本" placeholder="全部版本"
allLabel="全部版本" allLabel="全部版本"
/> />
@@ -253,8 +427,8 @@ function RequirementsPageContent() {
</div> </div>
) : ( ) : (
<> <>
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]"> <div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]"> <table className="min-w-[1120px] w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]"> <thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]"> <tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th> <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -382,7 +556,7 @@ function RequirementsPageContent() {
</> </>
)} )}
{/* 已采纳/已规划:关闭 */} {/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && ( {(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (
<button <button
onClick={() => updateRequirement(req.id, { status: 'closed' })} onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors" className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
@@ -411,6 +585,7 @@ function RequirementsPageContent() {
)} )}
</div> </div>
</div> </div>
</main>
{/* Detail drawer */} {/* Detail drawer */}
{viewingReq && ( {viewingReq && (
@@ -420,12 +595,13 @@ function RequirementsPageContent() {
types={types} types={types}
platforms={platforms} platforms={platforms}
sourceTargets={sourceTargets} sourceTargets={sourceTargets}
requirements={requirements}
resolveVersionName={resolveVersionName} resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)} onClose={() => setViewingReq(null)}
onEdit={() => handleEdit(viewingReq)} onEdit={canEditRequirement(viewingReq.id, devTasks) ? () => handleEdit(viewingReq) : undefined}
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }} onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }} onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
onCloseReq={() => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); }} onCloseReq={canCloseRequirement(viewingReq.id, devTasks) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }} onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
/> />
)} )}

View File

@@ -2,7 +2,7 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement'; import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, EFFORT_SHORT, EFFORT_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement'; import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface RequirementDetailProps { interface RequirementDetailProps {
requirement: Requirement; requirement: Requirement;
@@ -10,9 +10,10 @@ interface RequirementDetailProps {
types: DictItem[]; types: DictItem[];
platforms: DictItem[]; platforms: DictItem[];
sourceTargets: SourceTarget[]; sourceTargets: SourceTarget[];
requirements: Requirement[];
resolveVersionName: (id?: string) => string; resolveVersionName: (id?: string) => string;
onClose: () => void; onClose: () => void;
onEdit: () => void; onEdit?: () => void;
onAdopt?: () => void; onAdopt?: () => void;
onReject?: () => void; onReject?: () => void;
onCloseReq?: () => void; onCloseReq?: () => void;
@@ -20,11 +21,11 @@ interface RequirementDetailProps {
} }
const PRIORITY_COLORS: Record<string, string> = { const PRIORITY_COLORS: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600', P0: 'bg-red-50 text-red-600 border-red-200',
P1: 'bg-orange-500/10 text-orange-600', P1: 'bg-orange-50 text-orange-600 border-orange-200',
P2: 'bg-blue-500/10 text-blue-600', P2: 'bg-blue-50 text-blue-600 border-blue-200',
P3: 'bg-zinc-100 text-zinc-600', P3: 'bg-zinc-50 text-zinc-600 border-zinc-200',
P4: 'bg-zinc-100 text-zinc-500', P4: 'bg-zinc-50 text-zinc-500 border-zinc-200',
}; };
export function RequirementDetail({ export function RequirementDetail({
@@ -33,6 +34,7 @@ export function RequirementDetail({
types, types,
platforms, platforms,
sourceTargets, sourceTargets,
requirements,
resolveVersionName, resolveVersionName,
onClose, onClose,
onEdit, onEdit,
@@ -41,120 +43,136 @@ export function RequirementDetail({
onCloseReq, onCloseReq,
onDelete, onDelete,
}: RequirementDetailProps) { }: RequirementDetailProps) {
const canEdit = ['pending_review', 'adopted', 'planned'].includes(req.status); const canEdit = Boolean(onEdit) && ['pending_review', 'adopted', 'planned'].includes(req.status);
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-'; const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-'; const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-'; const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
const sourceName = `${SOURCE_TYPE_LABEL[req.sourceType]}${req.sourceTarget ? ` / ${req.sourceTarget}` : ''}`;
const sourceTargetName = sourceTargets.find((item) => item.name === req.sourceTarget)?.name ?? req.sourceTarget ?? '-';
const versionName = resolveVersionName(req.versionId);
const parentRequirement = req.parentId ? requirements.find((item) => item.id === req.parentId) : undefined;
const parentRequirementLabel = parentRequirement ? `${parentRequirement.code} · ${parentRequirement.title}` : req.parentId || '-';
const contextItems = [projectName, versionName, typeName].filter((item) => item && item !== '-');
return ( return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}> <div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
<div className="w-[480px] h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col shadow-xl" onClick={(e) => e.stopPropagation()}> <div
{/* Header */} className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg-card)] shadow-2xl"
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]"> onClick={(e) => e.stopPropagation()}
<div className="flex items-center gap-2.5"> >
<span className="text-[13px] font-semibold text-[var(--ink)]">{req.code}</span> <div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-5">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}> <div className="flex min-w-0 items-center gap-2.5">
{REQ_STATUS_LABEL[req.status]} <span className="shrink-0 text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
</span> <StatusBadge className={REQ_STATUS_COLOR[req.status]}>{REQ_STATUS_LABEL[req.status]}</StatusBadge>
<StatusBadge className={PRIORITY_COLORS[req.priority] ?? PRIORITY_COLORS.P3}>{req.priority}</StatusBadge>
</div> </div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"> <button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Actions */} <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-3">
<div className="flex items-center gap-2 px-5 py-3 border-b border-[var(--line)]"> {canEdit && <ActionButton onClick={onEdit}></ActionButton>}
{canEdit && ( {onAdopt && req.status === 'pending_review' && <ActionButton tone="success" onClick={onAdopt}></ActionButton>}
<button onClick={onEdit} className="h-7 px-3 rounded-md text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"> {onReject && req.status === 'pending_review' && <ActionButton tone="danger" onClick={onReject}></ActionButton>}
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && <ActionButton tone="warning" onClick={onCloseReq}></ActionButton>}
</button> {onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && <ActionButton tone="danger" onClick={onDelete}></ActionButton>}
)}
{onAdopt && req.status === 'pending_review' && (
<button onClick={onAdopt} className="h-7 px-3 rounded-md text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 transition-colors">
</button>
)}
{onReject && req.status === 'pending_review' && (
<button onClick={onReject} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && (
<button onClick={onCloseReq} className="h-7 px-3 rounded-md text-[12px] font-medium text-orange-600 border border-orange-200 hover:bg-orange-50 transition-colors">
</button>
)}
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && (
<button onClick={onDelete} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-500 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
</div> </div>
{/* Content */} <div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4"> <section className="border-b border-[var(--line)] px-5 py-5">
{/* 需求概述 */} <h2 className="text-[16px] font-semibold leading-snug text-[var(--ink)]">{req.title}</h2>
<div> <div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-[var(--ink-muted)]">
<h2 className="text-[15px] font-semibold text-[var(--ink)] leading-snug">{req.title}</h2> {contextItems.map((item) => <ContextPill key={item}>{item}</ContextPill>)}
</div> </div>
</section>
{/* 需求描述 */} <DetailSection title="需求描述">
{req.description && ( {req.description ? (
<div> <p className="whitespace-pre-wrap text-[13px] leading-6 text-[var(--ink-soft)]">{req.description}</p>
<Label></Label> ) : (
<p className="text-[13px] text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">{req.description}</p> <p className="text-[13px] text-[var(--ink-muted)]"></p>
</div>
)} )}
</DetailSection>
{/* 基本信息 */} <DetailSection title="基础信息">
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]"> <div className="grid grid-cols-2 gap-x-5 gap-y-4">
<FieldRow label="需求来源" value={`${SOURCE_TYPE_LABEL[req.sourceType]} · ${req.sourceTarget || '-'}`} /> <InfoItem label="需求来源" value={sourceName} />
<FieldRow label="所属项目" value={projectName} /> <InfoItem label="所属项目" value={projectName} />
<FieldRow label="需求类型" value={typeName} /> <InfoItem label="需求类型" value={typeName} />
<FieldRow label="支持端" value={platformNames} /> <InfoItem label="支持端" value={platformNames} />
<FieldRow label="优先级"> <InfoItem label="所属版本" value={versionName} />
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? ''}`}> <InfoItem label="父需求" value={parentRequirementLabel} />
{req.priority}
</span>
</FieldRow>
{req.effort && (
<FieldRow label="工作量">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${EFFORT_COLOR[req.effort]}`}>
{EFFORT_SHORT[req.effort]}
</span>
</FieldRow>
)}
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
</div> </div>
</DetailSection>
{/* 人员 & 日期 */} <DetailSection title="记录信息">
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]"> <div className="grid grid-cols-2 gap-x-5 gap-y-4">
<FieldRow label="产品负责人" value={req.productOwner || '-'} /> <InfoItem label="产品负责人" value={req.productOwner || '-'} />
<FieldRow label="录入人员" value={req.creator} /> <InfoItem label="录入人员" value={req.creator} />
<FieldRow label="录入日期" value={req.createdAt.slice(0, 10)} /> <InfoItem label="录入日期" value={req.createdAt.slice(0, 10)} />
<InfoItem label="来源对象" value={sourceTargetName} />
</div> </div>
</DetailSection>
{/* 关联父需求 */}
{req.parentId && (
<div className="rounded-lg border border-[var(--line)]">
<FieldRow label="父需求" value={req.parentId} />
</div>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
function Label({ children }: { children: React.ReactNode }) { function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
return <div className="text-[11px] font-medium text-[var(--ink-muted)] mb-1">{children}</div>;
}
function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
return ( return (
<div className="flex items-center justify-between px-4 py-2.5"> <section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span> <h3 className="mb-3 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
{children ?? <span className="text-[13px] text-[var(--ink)]">{value}</span>} {children}
</section>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<div className="text-[11px] text-[var(--ink-muted)]">{label}</div>
<div className="mt-1 truncate text-[13px] text-[var(--ink)]" title={value}>{value}</div>
</div> </div>
); );
} }
function StatusBadge({ className, children }: { className: string; children: React.ReactNode }) {
return (
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>
{children}
</span>
);
}
function ContextPill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex max-w-full items-center rounded-md bg-[var(--bg-subtle)] px-2 py-1 text-[11px] text-[var(--ink-soft)]">
{children}
</span>
);
}
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 (
<button onClick={onClick} className={`h-7 rounded-md border px-3 text-[12px] font-medium transition-colors ${toneClass}`}>
{children}
</button>
);
}

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-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 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 { interface RequirementModalProps {
open: boolean; open: boolean;
@@ -22,7 +22,6 @@ interface RequirementModalProps {
} }
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4']; 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 SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
const PRIORITY_COLORS: Record<Priority, string> = { const PRIORITY_COLORS: Record<Priority, string> = {
@@ -57,7 +56,6 @@ export function RequirementModal({
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]); const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [typeId, setTypeId] = useState(''); const [typeId, setTypeId] = useState('');
const [priority, setPriority] = useState<Priority>('P2'); const [priority, setPriority] = useState<Priority>('P2');
const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState(''); const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState(''); const [parentId, setParentId] = useState('');
const [versionId, setVersionId] = useState(''); const [versionId, setVersionId] = useState('');
@@ -86,7 +84,6 @@ export function RequirementModal({
setSelectedPlatforms(initial.platforms || []); setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || ''); setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2'); setPriority(initial.priority || 'P2');
setEffort(initial.effort || 'M');
setProductOwner(initial.productOwner || ''); setProductOwner(initial.productOwner || '');
setParentId(initial.parentId || ''); setParentId(initial.parentId || '');
} else { } else {
@@ -100,7 +97,6 @@ export function RequirementModal({
setSelectedPlatforms([]); setSelectedPlatforms([]);
setTypeId(''); setTypeId('');
setPriority('P2'); setPriority('P2');
setEffort('M');
setProductOwner(''); setProductOwner('');
setParentId(''); setParentId('');
} }
@@ -129,7 +125,7 @@ export function RequirementModal({
platforms: selectedPlatforms, platforms: selectedPlatforms,
typeId: typeId || undefined, typeId: typeId || undefined,
priority, priority,
effort, effort: initial?.effort ?? 'M',
status: initial?.status ?? 'pending_review', status: initial?.status ?? 'pending_review',
creator: initial?.creator || currentUserName || '系统', creator: initial?.creator || currentUserName || '系统',
parentId: parentId || undefined, parentId: parentId || undefined,

View File

@@ -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> = {}): 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);
});

View File

@@ -34,7 +34,16 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
*/ */
export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean { export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean {
const status = deriveReqDevStatus(reqId, devTasks); 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';
} }
/** /**

View File

@@ -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);
});

View File

@@ -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<Record<RequirementStatus, number>>;
}
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<Record<RequirementStatus, number>> = {};
for (const requirement of requirements) {
byStatus[requirement.status] = (byStatus[requirement.status] ?? 0) + 1;
}
return {
total: requirements.length,
byStatus,
};
}