feat(需求池): 调整需求池布局与详情展示
This commit is contained in:
@@ -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); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user