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

View File

@@ -34,10 +34,11 @@ import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetri
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
import { getRequirementCoverageSummary } from '@/lib/version-plan';
import { buildVersionDataScope } from '@/lib/version-data-scope';
import { buildVersionDataScope, selectVersionDataScope, type VersionDataScope } from '@/lib/version-data-scope';
import { calcScopedVersionProgress } from '@/lib/version-progress';
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
import { isVersionReadonly } from '@/lib/version-status';
import { loadV22VersionDetailData } from '@/lib/v22-api';
import {
VERSION_DEVELOPMENT_TYPE_OPTIONS,
buildVersionName,
@@ -89,12 +90,41 @@ export default function VersionDetailPage() {
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
const { requirements, fetchRequirements, updateRequirement, createRequirement } = useRequirementStore();
const {
requirements,
loaded: requirementsLoaded,
fetchRequirements,
updateRequirement,
createRequirement,
} = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore();
const {
plans,
loaded: plansLoaded,
fetchPlans,
createPlan,
updatePlan,
completePlan,
deletePlan,
} = useVersionPlanStore();
const {
tasks: devTasks,
loaded: devTasksLoaded,
fetchTasks: fetchDevTasks,
deleteTask: deleteDevTask,
} = useDevTaskStore();
const {
testCases,
loaded: testCasesLoaded,
fetchTestCases,
deleteTestCase,
} = useTestCaseStore();
const {
bugs,
loaded: bugsLoaded,
fetchBugs,
deleteBug,
} = useBugStore();
const user = useAuthStore((s) => s.user);
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
@@ -122,14 +152,63 @@ export default function VersionDetailPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [showReleaseModal, setShowReleaseModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => {
let active = true;
setV22Scope(null);
loadV22VersionDetailData(versionId)
.then((data) => {
if (active) setV22Scope(data.scope);
})
.catch(() => {
if (active) setV22Scope(null);
});
return () => { active = false; };
}, [versionId]);
useEffect(() => {
if (activeTab === 'overview') {
fetchRecords();
return;
}
if (activeTab === 'requirements') {
fetchRequirements();
fetchDevTasks();
return;
}
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
fetchRequirements();
fetchPlans();
return;
}
if (activeTab === 'tasks') {
fetchRequirements();
fetchDevTasks();
return;
}
if (activeTab === 'testcases') {
fetchRequirements();
fetchDevTasks();
fetchTestCases();
fetchBugs();
return;
}
if (activeTab === 'bugs') {
fetchRequirements();
fetchTestCases();
fetchBugs();
}
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]);
useEffect(() => {
if (!showRecommendModal) return;
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]);
useEffect(() => {
let active = true;
setRecommendationDataReady(false);
@@ -140,7 +219,7 @@ export default function VersionDetailPage() {
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const versionScope = useMemo(
const appDataVersionScope = useMemo(
() => version
? buildVersionDataScope({
versionId: version.id,
@@ -154,6 +233,27 @@ export default function VersionDetailPage() {
: null,
[version, requirements, plans, devTasks, testCases, bugs, records],
);
const versionWriteStoresReady = requirementsLoaded && plansLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
const requirementWriteReady = requirementsLoaded && devTasksLoaded;
const planWriteReady = requirementsLoaded && plansLoaded;
const devTaskWriteReady = requirementsLoaded && devTasksLoaded;
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
const loadAllVersionStores = () => {
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
};
const versionScope = useMemo(
() => selectVersionDataScope({
appDataScope: appDataVersionScope,
v22Scope: versionWriteStoresReady ? null : v22Scope,
}),
[appDataVersionScope, v22Scope, versionWriteStoresReady],
);
const releaseProgress = versionScope
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
: 0;
@@ -203,6 +303,15 @@ export default function VersionDetailPage() {
}
const scopedVersionData = versionScope!;
const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData;
const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements;
const displayPlans = plansLoaded ? appDataScopedVersionData.plans : scopedVersionData.plans;
const displayDevTasks = devTasksLoaded ? appDataScopedVersionData.devTasks : scopedVersionData.devTasks;
const displayTestCases = testCasesLoaded ? appDataScopedVersionData.testCases : scopedVersionData.testCases;
const displayBugs = bugsLoaded ? appDataScopedVersionData.bugs : scopedVersionData.bugs;
const displayRequirementIds = requirementsLoaded
? appDataScopedVersionData.requirementIds
: scopedVersionData.requirementIds;
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
@@ -244,6 +353,11 @@ export default function VersionDetailPage() {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
// 释放关联需求
if (!versionWriteStoresReady) {
loadAllVersionStores();
alert('版本关联数据正在加载,请稍后再删除。');
return;
}
scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
// 清理计划任务
scopedVersionData.plans.forEach((p) => deletePlan(p.id));
@@ -866,13 +980,13 @@ export default function VersionDetailPage() {
<VersionRequirementsTab
versionId={version.id}
projectId={version.projectId}
requirements={requirements}
devTasks={devTasks}
requirements={displayRequirements}
devTasks={displayDevTasks}
versionMembers={version.members ?? []}
currentUserName={user?.name ?? ''}
readOnly={versionReadonly}
readOnly={versionReadonly || !requirementWriteReady}
onLink={(ids, addedBy) => {
if (versionReadonly) return;
if (versionReadonly || !requirementWriteReady) return;
ids.forEach((id) => {
const requirement = requirements.find((item) => item.id === id);
if (!requirement) return;
@@ -880,13 +994,13 @@ export default function VersionDetailPage() {
});
}}
onUnlink={(id) => {
if (versionReadonly) return;
if (versionReadonly || !requirementWriteReady) return;
const requirement = requirements.find((item) => item.id === id);
if (!requirement) return;
updateRequirement(id, getRequirementVersionUnlinkPatch(requirement));
}}
onCreateChange={(data) => {
if (versionReadonly) return;
if (versionReadonly || !requirementWriteReady) return;
createRequirement({
...data,
productId: version.productId,
@@ -908,10 +1022,10 @@ export default function VersionDetailPage() {
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId);
return (
<PlanTab
plans={scopedVersionData.plans}
plans={displayPlans}
versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
@@ -919,10 +1033,10 @@ export default function VersionDetailPage() {
planType={pt}
versionMembers={version.members ?? []}
linkedRequirements={projectAdoptedReqs}
allRequirements={requirements}
readOnly={versionReadonly}
allRequirements={displayRequirements}
readOnly={versionReadonly || !planWriteReady}
onCreate={(data) => {
if (versionReadonly) return;
if (versionReadonly || !planWriteReady) return;
createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
@@ -935,18 +1049,18 @@ export default function VersionDetailPage() {
}
}}
onUpdate={(id, data) => {
if (versionReadonly) return;
if (versionReadonly || !planWriteReady) return;
updatePlan(id, data);
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
}}
onComplete={(id, result) => {
if (versionReadonly) return;
if (versionReadonly || !planWriteReady) return;
return completePlan(id, result);
}}
onDelete={(id) => {
if (versionReadonly) return;
if (versionReadonly || !planWriteReady) return;
deletePlan(id);
}}
/>
@@ -955,29 +1069,29 @@ export default function VersionDetailPage() {
) : activeTab === 'tasks' ? (
<DevTaskTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
requirementIds={displayRequirementIds}
versionDeadline={version.expectedReleaseDate ?? undefined}
readOnly={versionReadonly}
versionTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
readOnly={versionReadonly || !devTaskWriteReady}
versionTasks={displayDevTasks}
versionRequirements={displayRequirements}
/>
) : activeTab === 'testcases' ? (
<TestCaseTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionCases={scopedVersionData.testCases}
versionBugs={scopedVersionData.bugs}
versionDevTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
requirementIds={displayRequirementIds}
readOnly={versionReadonly || !testCaseWriteReady}
versionCases={displayTestCases}
versionBugs={displayBugs}
versionDevTasks={displayDevTasks}
versionRequirements={displayRequirements}
/>
) : activeTab === 'bugs' ? (
<BugTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionBugs={scopedVersionData.bugs}
versionTestCases={scopedVersionData.testCases}
requirementIds={displayRequirementIds}
readOnly={versionReadonly || !bugWriteReady}
versionBugs={displayBugs}
versionTestCases={displayTestCases}
/>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
@@ -28,6 +28,8 @@ import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case'
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status';
import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api';
import { selectWorkspaceCollections } from '@/lib/workspace-v22-source';
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
@@ -54,11 +56,11 @@ const PLAN_STATUS_LABEL: Record<string, string> = { pending: '未开始', in_pro
export default function WorkspacePage() {
const router = useRouter();
const { overview, fetchOverview } = useProductStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const { plans, fetchPlans, loaded: plansLoaded } = useVersionPlanStore();
const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore();
const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore();
const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore();
const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore();
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
const { activities, fetchActivities } = useWorkActivityStore();
const user = useAuthStore((s) => s.user);
@@ -67,17 +69,62 @@ export default function WorkspacePage() {
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
const [bugFromTestCaseId, setBugFromTestCaseId] = useState<string | null>(null);
const [v22WorkspaceData, setV22WorkspaceData] = useState<V22WorkspaceData | null>(null);
const [v22WorkspaceLoaded, setV22WorkspaceLoaded] = useState(false);
const [v22WorkspaceFailed, setV22WorkspaceFailed] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
useEffect(() => { fetchActivities(); }, [fetchActivities]);
const userName = user?.name ?? '';
const workspaceUserKey = userName || user?.id || '';
useEffect(() => {
if (!workspaceUserKey.trim()) {
setV22WorkspaceData(null);
setV22WorkspaceLoaded(false);
setV22WorkspaceFailed(false);
return;
}
let cancelled = false;
setV22WorkspaceLoaded(false);
setV22WorkspaceFailed(false);
loadV22WorkspaceData(workspaceUserKey)
.then((data) => {
if (cancelled) return;
setV22WorkspaceData(data);
setV22WorkspaceLoaded(true);
})
.catch(() => {
if (cancelled) return;
setV22WorkspaceData(null);
setV22WorkspaceFailed(true);
});
return () => {
cancelled = true;
};
}, [workspaceUserKey]);
useEffect(() => {
if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return;
void fetchPlans();
void fetchRequirements();
void fetchTasks();
void fetchTestCases();
void fetchBugs();
}, [
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
v22WorkspaceFailed,
workspaceUserKey,
]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionMap = useMemo(() => {
@@ -92,9 +139,27 @@ export default function WorkspacePage() {
return map;
}, [requirements]);
const workspaceCollections = useMemo(
() => selectWorkspaceCollections({
v22Loaded: v22WorkspaceLoaded,
v22Failed: v22WorkspaceFailed,
v22Data: v22WorkspaceData,
appData: { plans, devTasks, testCases, bugs },
}),
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
);
const workItems = useMemo(() =>
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
aggregateWorkItems(
userName,
workspaceCollections.versionPlans,
workspaceCollections.devTasks,
workspaceCollections.testCases,
workspaceCollections.bugs,
versionMap,
requirementVersionMap,
),
[userName, workspaceCollections, versionMap, requirementVersionMap]
);
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
@@ -158,6 +223,32 @@ export default function WorkspacePage() {
const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined;
const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined;
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => {
const pendingLoads: Array<Promise<void>> = [];
if (!requirementsLoaded) pendingLoads.push(fetchRequirements());
if ((item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') && !plansLoaded) {
pendingLoads.push(fetchPlans());
}
if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks());
if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases());
if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs());
await Promise.all(pendingLoads);
}, [
bugsLoaded,
devTasksLoaded,
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
plansLoaded,
requirementsLoaded,
testCasesLoaded,
]);
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
await ensureWorkspaceDrawerStores(item);
setDrawerItem(item);
}, [ensureWorkspaceDrawerStores]);
return (
<div className="flex h-full">
@@ -245,7 +336,7 @@ export default function WorkspacePage() {
item={item}
versionStatus={versionMap.get(item.versionId)?.status}
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
onClick={() => setDrawerItem(item)}
onClick={() => { void openWorkItemDrawer(item); }}
/>
))}
</div>