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 { 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 (
<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() {
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<RequirementScopeSelection>({ 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 (
<div className="flex h-full flex-col">
{/* 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="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<div className="flex h-full overflow-hidden bg-[var(--bg)]">
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-4">
<div>
<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)]">
{requirements.length}
</span>
</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">
<button
onClick={() => setDrawerType('source')}
@@ -206,15 +389,6 @@ function RequirementsPageContent() {
))}
</div>
{/* Project dropdown */}
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
placeholder="全部项目"
allLabel="全部项目"
/>
{/* Priority dropdown */}
<FilterSelect
value={priorityFilter}
@@ -237,7 +411,7 @@ function RequirementsPageContent() {
<FilterSelect
value={versionFilter}
onChange={setVersionFilter}
options={allVersions.map((v) => ({ value: v.id, label: v.name }))}
options={scopedVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
@@ -253,8 +427,8 @@ function RequirementsPageContent() {
</div>
) : (
<>
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="min-w-[1120px] w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 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>
@@ -382,7 +556,7 @@ function RequirementsPageContent() {
</>
)}
{/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && (
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (
<button
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"
@@ -411,6 +585,7 @@ function RequirementsPageContent() {
)}
</div>
</div>
</main>
{/* Detail drawer */}
{viewingReq && (
@@ -420,12 +595,13 @@ function RequirementsPageContent() {
types={types}
platforms={platforms}
sourceTargets={sourceTargets}
requirements={requirements}
resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)}
onEdit={() => handleEdit(viewingReq)}
onEdit={canEditRequirement(viewingReq.id, devTasks) ? () => handleEdit(viewingReq) : undefined}
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); 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); }}
/>
)}