Files
ftb-project-management/apps/web/app/requirements/page.tsx
2026-07-03 11:53:25 +08:00

922 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from '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';
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 { buildRequirementDevStatusMap, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
import {
REQUIREMENT_TABLE_BADGE_CLASS,
REQUIREMENT_TABLE_CLASS,
REQUIREMENT_TABLE_COLUMN_WIDTHS,
REQUIREMENT_TABLE_CONTAINER_CLASS,
REQUIREMENT_TABLE_HEADER_CELL_CLASS,
REQUIREMENT_TABLE_NOWRAP_CELL_CLASS,
REQUIREMENT_TABLE_SMALL_BADGE_CLASS,
getRequirementTableTextClass,
} from '@/lib/requirement-table-layout';
import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link';
import { Pagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
import { FilterSelect } from '@/components/FilterSelect';
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
import { loadV22RequirementsPage } from '@/lib/v22-api';
const PRIORITY_COLORS: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending_review', label: '待评审' },
{ key: 'adopted', label: '已采纳' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'developing', label: '开发中' },
{ key: 'dev_completed', label: '已完成' },
{ key: 'released', label: '已上线' },
{ key: 'closed', label: '已关闭' },
];
export default function RequirementsPage() {
return (
<RouteGuard permission="requirement:view">
<RequirementsPageContent />
</RouteGuard>
);
}
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,
sourceTargets, types, platforms,
addSourceTarget, updateSourceTarget, deleteSourceTarget,
addType, updateType, deleteType,
addPlatform, updatePlatform, deletePlatform,
loaded: requirementsLoaded,
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '系统';
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const requirementDevStatusMap = useMemo(() => buildRequirementDevStatusMap(devTasks), [devTasks]);
const devTasksByRequirement = useMemo(() => {
const map = new Map<string, typeof devTasks>();
for (const task of devTasks) {
if (!task.requirementId) continue;
const items = map.get(task.requirementId) ?? [];
items.push(task);
map.set(task.requirementId, items);
}
return map;
}, [devTasks]);
const [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
const [scopeSearch, setScopeSearch] = useState('');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(20);
const [autoScopeSelected, setAutoScopeSelected] = useState(false);
const [v22Requirements, setV22Requirements] = useState<Requirement[]>([]);
const [v22NextCursor, setV22NextCursor] = useState<string | undefined>();
const [v22CursorByPage, setV22CursorByPage] = useState<Record<number, string | undefined>>({ 1: undefined });
const [v22RequirementsLoading, setV22RequirementsLoading] = useState(false);
const [v22RequirementsFailed, setV22RequirementsFailed] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
const selectedScopeKey = selectedScope.type === 'all'
? 'all'
: selectedScope.type === 'product'
? `product:${selectedScope.productId}`
: `project:${selectedScope.projectId}`;
useEffect(() => {
setVersionFilter('all');
}, [selectedScopeKey]);
useEffect(() => {
if (autoScopeSelected || selectedScope.type !== 'all') return;
const firstProductId = overview[0]?.id;
if (!firstProductId) return;
setSelectedScope({ type: 'product', productId: firstProductId });
setAutoScopeSelected(true);
}, [autoScopeSelected, overview, selectedScope.type]);
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
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 filterResetKey = useMemo(() => JSON.stringify({
selectedScopeKey,
search,
statusFilter,
priorityFilter,
typeFilter,
versionFilter,
dateSort,
}), [dateSort, priorityFilter, search, selectedScopeKey, statusFilter, typeFilter, versionFilter]);
useEffect(() => {
setPage(1);
setV22CursorByPage({ 1: undefined });
setV22Requirements([]);
setV22NextCursor(undefined);
}, [filterResetKey]);
const v22Cursor = v22CursorByPage[page];
const v22RequirementQuery = useMemo(() => buildV22RequirementQuery({
selectedScope,
projects: allProjects.map((project) => ({ id: project.id, productId: project.productId })),
statusFilter,
priorityFilter,
typeFilter,
versionFilter,
search,
dateSort,
limit: pageSize,
cursor: v22Cursor,
}), [
allProjects,
dateSort,
pageSize,
priorityFilter,
search,
selectedScope,
statusFilter,
typeFilter,
versionFilter,
v22Cursor,
]);
useEffect(() => {
if (v22RequirementQuery && !v22RequirementsFailed) return;
fetchRequirements();
fetchDevTasks();
}, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]);
useEffect(() => {
setV22RequirementsFailed(false);
}, [filterResetKey, pageSize]);
useEffect(() => {
if (!v22RequirementQuery) {
setV22Requirements([]);
setV22NextCursor(undefined);
return;
}
let cancelled = false;
setV22RequirementsLoading(true);
loadV22RequirementsPage(v22RequirementQuery)
.then((result) => {
if (cancelled) return;
setV22Requirements(result.items);
setV22NextCursor(result.nextCursor);
if (result.nextCursor) {
setV22CursorByPage((current) => (
current[page + 1] === result.nextCursor
? current
: { ...current, [page + 1]: result.nextCursor }
));
}
})
.catch(() => {
if (cancelled) return;
setV22Requirements([]);
setV22NextCursor(undefined);
setV22RequirementsFailed(true);
fetchRequirements();
fetchDevTasks();
})
.finally(() => {
if (!cancelled) setV22RequirementsLoading(false);
});
return () => {
cancelled = true;
};
}, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]);
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 '-';
for (const product of overview) {
const version = product.versions?.find((v: any) => v.id === versionId);
if (version) return version.name;
}
return '-';
};
const filtered = useMemo(() => {
let list = [...scopedRequirements];
// search filter
if (search) {
const q = search.toLowerCase();
list = list.filter(
(r) => r.code.toLowerCase().includes(q) || r.title.toLowerCase().includes(q),
);
}
// status filter
if (statusFilter !== 'all') {
list = statusFilter === 'dev_completed'
? list.filter((r) => requirementDevStatusMap.get(r.id) === 'completed')
: list.filter((r) => r.status === statusFilter);
}
// priority filter
if (priorityFilter !== 'all') {
list = list.filter((r) => r.priority === priorityFilter);
}
// type filter
if (typeFilter !== 'all') {
list = list.filter((r) => r.typeId === typeFilter);
}
if (versionFilter !== 'all') {
list = list.filter((r) => r.versionId === versionFilter);
}
list = sortRequirementsByCreatedAt(list, dateSort);
return list;
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]);
const appDataRequirementIds = useMemo(() => new Set(requirements.map((item) => item.id)), [requirements]);
const usingV22Requirements = Boolean(v22RequirementQuery) && !v22RequirementsFailed;
const appTotalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const appPage = Math.min(page, appTotalPages);
const appPaged = filtered.slice((appPage - 1) * pageSize, appPage * pageSize);
const displayedRequirements = usingV22Requirements ? v22Requirements : appPaged;
const total = usingV22Requirements
? (page - 1) * pageSize + v22Requirements.length + (v22NextCursor ? 1 : 0)
: filtered.length;
const displayPage = usingV22Requirements ? page : appPage;
const displayedScopeCount = usingV22Requirements ? total : scopedRequirements.length;
const canMutateRequirement = (req: Requirement) => requirementsLoaded && appDataRequirementIds.has(req.id);
const handlePageChange = (nextPage: number) => {
if (usingV22Requirements && nextPage > page && !v22CursorByPage[nextPage]) return;
setPage(nextPage);
};
const setPageSize = (size: number) => {
setPageSizeState(size);
setPage(1);
setV22CursorByPage({ 1: undefined });
};
const handleEdit = (req: Requirement) => {
if (!canMutateRequirement(req)) return;
setViewingReq(null);
setEditingReq(req);
setShowModal(true);
};
const handleRowClick = (req: Requirement) => {
setViewingReq(req);
};
const handleCreate = async () => {
await fetchRequirements();
setEditingReq(null);
setShowModal(true);
};
const handleSelectScope = (scope: RequirementScopeSelection) => {
setAutoScopeSelected(true);
setSelectedScope(scope);
if (scope.type === 'all') {
fetchRequirements();
fetchDevTasks();
}
};
return (
<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="shrink-0 border-b border-[var(--line)] px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
value={scopeSearch}
onChange={(event) => setScopeSearch(event.target.value)}
placeholder="搜索产品或项目"
aria-label="搜索产品或项目"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
<button
onClick={() => handleSelectScope({ 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>
{searchedScopeTree.map((product) => (
<RequirementProductNode
key={product.id}
product={product}
selectedScope={selectedScope}
onSelect={handleSelectScope}
/>
))}
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
<div className="mt-3 rounded-lg border border-dashed border-[var(--line)] px-3 py-4 text-center text-[12px] text-[var(--ink-muted)]">
</div>
)}
</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)]">
{displayedScopeCount}
</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={() => { fetchRequirements(); setDrawerType('source'); }}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button
onClick={handleCreate}
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
</header>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索编号或标题"
className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
{/* Status tabs */}
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${
statusFilter === tab.key
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Priority dropdown */}
<FilterSelect
value={priorityFilter}
onChange={setPriorityFilter}
options={['P0', 'P1', 'P2', 'P3', 'P4'].map((p) => ({ value: p, label: p }))}
placeholder="全部优先级"
allLabel="全部优先级"
/>
{/* Type dropdown */}
<FilterSelect
value={typeFilter}
onChange={setTypeFilter}
options={types.map((t) => ({ value: t.id, label: t.name }))}
placeholder="全部类型"
allLabel="全部类型"
/>
{/* Version dropdown */}
<FilterSelect
value={versionFilter}
onChange={setVersionFilter}
options={scopedVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
<div className="px-5 py-4">
{displayedRequirements.length === 0 && !v22RequirementsLoading ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<>
<div className={REQUIREMENT_TABLE_CONTAINER_CLASS}>
<table className={REQUIREMENT_TABLE_CLASS}>
<colgroup>
{REQUIREMENT_TABLE_COLUMN_WIDTHS.map((width, index) => (
<col key={index} style={{ width: `${width}%` }} />
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th
className={`${REQUIREMENT_TABLE_HEADER_CELL_CLASS} cursor-pointer select-none transition-colors hover:text-[var(--ink-soft)]`}
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
>
<span className="inline-flex items-center gap-1">
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
</span>
</th>
<th className={`${REQUIREMENT_TABLE_HEADER_CELL_CLASS} text-right`}></th>
</tr>
</thead>
<tbody>
{displayedRequirements.map((req) => (
<tr
key={req.id}
onClick={() => handleRowClick(req)}
className="border-b border-[var(--line-soft)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--bg-subtle)]"
>
{/* 需求编号 */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="font-medium text-[var(--ink)]">{req.code}</span>
</td>
{/* 需求概述 */}
<td className="px-4 py-3">
<span className="block max-w-[200px] truncate text-[var(--ink)]">{req.title}</span>
</td>
{/* 需求来源 */}
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
{(() => {
const sourceText = `${SOURCE_TYPE_LABEL[req.sourceType]}: ${req.sourceTarget}`;
return (
<span className={getRequirementTableTextClass('source')} title={sourceText}>
{sourceText}
</span>
);
})()}
</td>
{/* 所属项目 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{(() => {
const projectName = allProjects.find((p) => p.id === req.projectId)?.name ?? '-';
return (
<span className={getRequirementTableTextClass('project')} title={projectName}>
{projectName}
</span>
);
})()}
</td>
{/* 需求类型 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{(() => {
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
return (
<span className={getRequirementTableTextClass('type')} title={typeName}>
{typeName}
</span>
);
})()}
</td>
{/* 状态 */}
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
<span className={`${REQUIREMENT_TABLE_BADGE_CLASS} ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</td>
{/* 优先级 */}
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
{req.priority}
</span>
</td>
{/* 所属版本 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{(() => {
const versionName = resolveVersionName(req.versionId);
return (
<span className={getRequirementTableTextClass('version')} title={versionName}>
{versionName}
</span>
);
})()}
</td>
{/* 开发状态 */}
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
{(() => {
const devStatus = requirementDevStatusMap.get(req.id) ?? 'unscheduled';
return (
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
{REQ_DEV_STATUS_LABEL[devStatus]}
</span>
);
})()}
</td>
{/* 录入人员 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
<span className={getRequirementTableTextClass('creator')} title={req.creator}>
{req.creator}
</span>
</td>
{/* 录入日期 */}
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums whitespace-nowrap">
{new Date(req.createdAt).toISOString().slice(0, 10)}
</td>
{/* 操作 */}
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-0.5 whitespace-nowrap">
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
{canMutateRequirement(req) && (['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
<button
onClick={() => handleEdit(req)}
className="h-6 rounded px-1.5 text-[11px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)]"
>
</button>
)}
{/* 待评审:采纳/拒绝 */}
{canMutateRequirement(req) && req.status === 'pending_review' && (
<>
<button
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
className="h-6 rounded px-1.5 text-[11px] font-medium text-emerald-600 transition-colors hover:bg-emerald-50"
>
</button>
<button
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-600 transition-colors hover:bg-red-50"
>
</button>
</>
)}
{/* 已采纳/已规划:关闭 */}
{canMutateRequirement(req) && (req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
<button
onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 rounded px-1.5 text-[11px] font-medium text-orange-600 transition-colors hover:bg-orange-50"
>
</button>
)}
{/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */}
{canMutateRequirement(req) && (['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
<button
onClick={() => deleteRequirement(req.id)}
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-50"
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={displayPage} pageSize={pageSize} onChange={handlePageChange} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
</main>
{/* Detail drawer */}
{viewingReq && (
<RequirementDetail
requirement={viewingReq}
projects={allProjects.map((p) => ({ id: p.id, name: p.name }))}
types={types}
platforms={platforms}
sourceTargets={sourceTargets}
requirements={requirements}
resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)}
onEdit={canMutateRequirement(viewingReq) && canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
onAdopt={canMutateRequirement(viewingReq) ? () => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); } : undefined}
onReject={canMutateRequirement(viewingReq) ? () => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); } : undefined}
onCloseReq={canMutateRequirement(viewingReq) && canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
onDelete={canMutateRequirement(viewingReq) ? () => { deleteRequirement(viewingReq.id); setViewingReq(null); } : undefined}
/>
)}
{/* Modal */}
{showModal && (
<RequirementModal
open={true}
initial={editingReq}
products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions.map((v) => ({ id: v.id, name: v.name, projectId: v.projectId }))}
sourceTargets={sourceTargets}
types={types}
platforms={platforms}
requirements={requirements}
currentUserName={currentUserName}
onClose={() => { setShowModal(false); setEditingReq(null); }}
onSubmit={(data) => {
if (editingReq) {
const versionPatch = getRequirementVersionSelectionPatch(
editingReq,
data.versionId,
data.versionId !== editingReq.versionId ? currentUserName : editingReq.addedToVersionBy,
);
updateRequirement(editingReq.id, { ...data, ...versionPatch });
} else {
createRequirement(data);
}
setShowModal(false);
setEditingReq(null);
}}
onOpenDrawer={(type) => setDrawerType(type)}
/>
)}
{/* Drawer */}
{drawerType === 'source' && (
<SourceDrawer
open={true}
items={sourceTargets}
onClose={() => setDrawerType(null)}
onAdd={addSourceTarget}
onUpdate={updateSourceTarget}
onDelete={deleteSourceTarget}
/>
)}
{drawerType === 'type' && (
<DictDrawer
open={true}
title="需求类型管理"
items={types}
onClose={() => setDrawerType(null)}
onAdd={addType}
onUpdate={updateType}
onDelete={deleteType}
/>
)}
{drawerType === 'platform' && (
<DictDrawer
open={true}
title="支持端管理"
items={platforms}
onClose={() => setDrawerType(null)}
onAdd={addPlatform}
onUpdate={updatePlatform}
onDelete={deletePlatform}
/>
)}
{/* 拒绝原因弹窗 */}
{rejectingReq && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setRejectingReq(null)}>
<div className="w-full max-w-sm rounded-xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-3"></h3>
<p className="text-[12px] text-[var(--ink-muted)] mb-2">{rejectingReq.title}</p>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请填写拒绝原因..."
rows={3}
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
<div className="flex justify-end gap-2 mt-3">
<button onClick={() => setRejectingReq(null)} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button
onClick={() => {
if (!rejectReason.trim()) return;
updateRequirement(rejectingReq.id, { status: 'rejected', description: rejectingReq.description + `\n\n【拒绝原因】${rejectReason.trim()}` });
setRejectingReq(null);
}}
disabled={!rejectReason.trim()}
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}