feat(v2.2): 完成高频读取热路径

This commit is contained in:
Script Generator
2026-07-03 11:53:25 +08:00
parent 5a90923031
commit 70460c3273
28 changed files with 3042 additions and 121 deletions

View File

@@ -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}
/>
)}