965 lines
42 KiB
TypeScript
965 lines
42 KiB
TypeScript
'use client';
|
||
|
||
import { RouteGuard } from '@/components/auth/Guard';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Lightbulb,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
FolderOpen,
|
||
Layers,
|
||
Pencil,
|
||
Check,
|
||
XCircle,
|
||
Archive,
|
||
Trash2,
|
||
} 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 REQUIREMENT_ACTION_BUTTON_BASE_CLASS =
|
||
'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-[var(--ink-muted)] transition-colors';
|
||
|
||
const REQUIREMENT_ACTION_BUTTON_TONE_CLASS = {
|
||
neutral: 'hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]',
|
||
success: 'text-emerald-600 hover:bg-emerald-50',
|
||
danger: 'text-red-600 hover:bg-red-50',
|
||
warning: 'text-orange-600 hover:bg-orange-50',
|
||
} as const;
|
||
|
||
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 appDataRequirementById = useMemo(() => new Map(requirements.map((item) => [item.id, item])), [requirements]);
|
||
const appDataRequirementIds = useMemo(() => new Set(appDataRequirementById.keys()), [appDataRequirementById]);
|
||
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 = useMemo(() => {
|
||
if (!usingV22Requirements) return appPaged;
|
||
return v22Requirements.map((req) => {
|
||
if (req.creator) return req;
|
||
const appDataRequirement = appDataRequirementById.get(req.id);
|
||
return appDataRequirement?.creator ? { ...req, creator: appDataRequirement.creator } : req;
|
||
});
|
||
}, [appDataRequirementById, appPaged, usingV22Requirements, v22Requirements]);
|
||
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={`${REQUIREMENT_ACTION_BUTTON_BASE_CLASS} ${REQUIREMENT_ACTION_BUTTON_TONE_CLASS.neutral}`}
|
||
title="编辑"
|
||
aria-label="编辑需求"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||
</button>
|
||
)}
|
||
{/* 待评审:采纳/拒绝 */}
|
||
{canMutateRequirement(req) && req.status === 'pending_review' && (
|
||
<>
|
||
<button
|
||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
||
className={`${REQUIREMENT_ACTION_BUTTON_BASE_CLASS} ${REQUIREMENT_ACTION_BUTTON_TONE_CLASS.success}`}
|
||
title="采纳"
|
||
aria-label="采纳需求"
|
||
>
|
||
<Check className="h-3.5 w-3.5" strokeWidth={2} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
|
||
className={`${REQUIREMENT_ACTION_BUTTON_BASE_CLASS} ${REQUIREMENT_ACTION_BUTTON_TONE_CLASS.danger}`}
|
||
title="拒绝"
|
||
aria-label="拒绝需求"
|
||
>
|
||
<XCircle className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||
</button>
|
||
</>
|
||
)}
|
||
{/* 已采纳/已规划:关闭 */}
|
||
{canMutateRequirement(req) && (req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||
<button
|
||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
||
className={`${REQUIREMENT_ACTION_BUTTON_BASE_CLASS} ${REQUIREMENT_ACTION_BUTTON_TONE_CLASS.warning}`}
|
||
title="关闭"
|
||
aria-label="关闭需求"
|
||
>
|
||
<Archive className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||
</button>
|
||
)}
|
||
{/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */}
|
||
{canMutateRequirement(req) && (['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||
<button
|
||
onClick={() => deleteRequirement(req.id)}
|
||
className={`${REQUIREMENT_ACTION_BUTTON_BASE_CLASS} ${REQUIREMENT_ACTION_BUTTON_TONE_CLASS.danger}`}
|
||
title="删除"
|
||
aria-label="删除需求"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.8} />
|
||
</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>
|
||
);
|
||
}
|