feat(v2.2): 完成高频读取热路径
This commit is contained in:
@@ -24,11 +24,13 @@ import {
|
||||
getRequirementTableTextClass,
|
||||
} from '@/lib/requirement-table-layout';
|
||||
import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
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',
|
||||
@@ -175,6 +177,7 @@ function RequirementsPageContent() {
|
||||
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
||||
addType, updateType, deleteType,
|
||||
addPlatform, updatePlatform, deletePlatform,
|
||||
loaded: requirementsLoaded,
|
||||
} = useRequirementStore();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||
@@ -209,10 +212,16 @@ function RequirementsPageContent() {
|
||||
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]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||
|
||||
const selectedScopeKey = selectedScope.type === 'all'
|
||||
? 'all'
|
||||
@@ -224,6 +233,14 @@ function RequirementsPageContent() {
|
||||
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]);
|
||||
@@ -233,6 +250,97 @@ function RequirementsPageContent() {
|
||||
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 ?? '产品需求';
|
||||
@@ -298,9 +406,30 @@ function RequirementsPageContent() {
|
||||
return list;
|
||||
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
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);
|
||||
@@ -310,11 +439,21 @@ function RequirementsPageContent() {
|
||||
setViewingReq(req);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
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)]">
|
||||
@@ -341,7 +480,7 @@ function RequirementsPageContent() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => setSelectedScope({ type: 'all' })}
|
||||
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)]'
|
||||
@@ -357,7 +496,7 @@ function RequirementsPageContent() {
|
||||
key={product.id}
|
||||
product={product}
|
||||
selectedScope={selectedScope}
|
||||
onSelect={setSelectedScope}
|
||||
onSelect={handleSelectScope}
|
||||
/>
|
||||
))}
|
||||
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
||||
@@ -375,14 +514,14 @@ function RequirementsPageContent() {
|
||||
<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}
|
||||
{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={() => setDrawerType('source')}
|
||||
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} />
|
||||
@@ -458,7 +597,7 @@ function RequirementsPageContent() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
||||
<div className="px-5 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
{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>
|
||||
@@ -497,7 +636,7 @@ function RequirementsPageContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((req) => (
|
||||
{displayedRequirements.map((req) => (
|
||||
<tr
|
||||
key={req.id}
|
||||
onClick={() => handleRowClick(req)}
|
||||
@@ -603,7 +742,7 @@ function RequirementsPageContent() {
|
||||
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-0.5 whitespace-nowrap">
|
||||
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
|
||||
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
{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)]"
|
||||
@@ -612,7 +751,7 @@ function RequirementsPageContent() {
|
||||
</button>
|
||||
)}
|
||||
{/* 待评审:采纳/拒绝 */}
|
||||
{req.status === 'pending_review' && (
|
||||
{canMutateRequirement(req) && req.status === 'pending_review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
||||
@@ -629,7 +768,7 @@ function RequirementsPageContent() {
|
||||
</>
|
||||
)}
|
||||
{/* 已采纳/已规划:关闭 */}
|
||||
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
{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"
|
||||
@@ -638,7 +777,7 @@ function RequirementsPageContent() {
|
||||
</button>
|
||||
)}
|
||||
{/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */}
|
||||
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||||
{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"
|
||||
@@ -653,7 +792,7 @@ function RequirementsPageContent() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
<Pagination total={total} page={displayPage} pageSize={pageSize} onChange={handlePageChange} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -671,11 +810,11 @@ function RequirementsPageContent() {
|
||||
requirements={requirements}
|
||||
resolveVersionName={resolveVersionName}
|
||||
onClose={() => setViewingReq(null)}
|
||||
onEdit={canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
|
||||
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
||||
onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
||||
onDelete={() => { deleteRequirement(viewingReq.id); 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user