perf(web): 优化大数据量页面切换与聚合性能

This commit is contained in:
Script Generator
2026-07-03 09:43:36 +08:00
parent 554ab520d1
commit a509bb4922
32 changed files with 1087 additions and 360 deletions

View File

@@ -15,11 +15,13 @@ import { useMemberStore } from '@/stores/useMemberStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task';
import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
import { calcScopedVersionProgress } from '@/lib/version-progress';
import { buildVersionDataScopeMap, type VersionDataScope } from '@/lib/version-data-scope';
import { formatActualDuration } from '@/lib/work-hours';
import type { DevTask } from '@/lib/dev-task';
import type { TestCase } from '@/lib/test-case';
@@ -73,14 +75,10 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number
}
/* ─── VersionCard ─── */
function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requirements, onNavigate }: {
function VersionCard({ version, progress, scope, onNavigate }: {
version: VersionWithContext;
progress: number;
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
requirements: { id: string; versionId?: string }[];
scope: VersionDataScope;
onNavigate: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status));
@@ -89,15 +87,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
setExpanded(getVersionCardDefaultExpanded(version.status));
}, [version.status]);
const versionData = useMemo(() => {
const vPlans = plans.filter((p) => p.versionId === version.id);
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTCs = testCases.filter((c) => c.versionId === version.id);
const vBugs = bugs.filter((b) => b.versionId === version.id);
return { vPlans, vDevTasks, vTCs, vBugs };
}, [version.id, plans, devTasks, testCases, bugs, requirements]);
const versionData = useMemo(() => ({
vPlans: scope.plans,
vDevTasks: scope.devTasks,
vTCs: scope.testCases,
vBugs: scope.bugs,
}), [scope]);
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
plans: versionData.vPlans,
@@ -373,85 +368,39 @@ export default function ProjectDetailPage() {
}, [project, statusFilter, isSuperAdmin, currentUserName]);
// Compute actual overall progress per version
const versionProgressMap = useMemo(() => {
if (!project) return {} as Record<string, number>;
const map: Record<string, number> = {};
for (const v of project.versions) {
const vPlans = plans.filter((p) => p.versionId === v.id);
const vReqs = requirements.filter((r) => r.versionId === v.id);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTestCases = testCases.filter((c) => c.versionId === v.id);
const versionScopeMap = useMemo(
() => project ? buildVersionDataScopeMap({
versionIds: project.versions.map((version) => version.id),
plans,
requirements,
devTasks,
testCases,
bugs,
}) : {},
[project, plans, requirements, devTasks, testCases, bugs],
);
const segments: number[] = [];
const researchPlans = vPlans.filter((p) => p.type === 'research');
if (researchPlans.length > 0) {
const totals = researchPlans.reduce((acc, p) => {
const tasks = p.tasks || [];
acc.total += tasks.length;
acc.done += tasks.filter((t) => t.status === 'completed').length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const productPlans = vPlans.filter((p) => p.type === 'product');
if (productPlans.length > 0) {
const totals = productPlans.reduce((acc, p) => {
const summary = getRequirementCoverageSummary(p);
acc.total += summary.total;
acc.done += summary.completed;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const uiPlans = vPlans.filter((p) => p.type === 'ui');
if (uiPlans.length > 0) {
const totals = uiPlans.reduce((acc, p) => {
const summary = getRequirementCoverageSummary(p);
acc.total += summary.total;
acc.done += summary.completed;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
}
if (vTestCases.length > 0) {
const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
segments.push((executed / vTestCases.length) * 100);
}
map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
return map;
}, [project, plans, requirements, devTasks, testCases]);
const versionProgressMap = useMemo(
() => Object.fromEntries(
Object.entries(versionScopeMap).map(([id, scope]) => [
id,
calcScopedVersionProgress(scope.plans, scope.devTasks, scope.testCases),
]),
),
[versionScopeMap],
);
const stats = useMemo(() => {
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
const total = project.versions.length;
const released = project.versions.filter((v) => v.status === 'released').length;
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const versionIds = new Set(project.versions.map((v) => v.id));
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId));
const versionScopes = Object.values(versionScopeMap);
const bugCount = versionScopes.reduce((sum, scope) => sum + scope.bugs.length, 0);
const projectDevTasks = versionScopes.flatMap((scope) => scope.devTasks);
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
}, [project, requirements, bugs, devTasks, projectId]);
}, [project, requirements, projectId, versionScopeMap]);
const teamByRole = useMemo(() => {
if (!project) return {} as Record<string, Record<string, number>>;
@@ -524,7 +473,7 @@ export default function ProjectDetailPage() {
{sortedVersions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]"></div>
) : (
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} plans={plans} devTasks={devTasks} testCases={testCases} bugs={bugs} requirements={requirements} onNavigate={(id) => router.push(`/versions/${id}`)} />)
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} scope={versionScopeMap[v.id]} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>

View File

@@ -10,11 +10,15 @@ 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 { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
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,
@@ -178,6 +182,17 @@ function RequirementsPageContent() {
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('');
@@ -261,7 +276,7 @@ function RequirementsPageContent() {
// status filter
if (statusFilter !== 'all') {
list = statusFilter === 'dev_completed'
? list.filter((r) => deriveReqDevStatus(r.id, devTasks) === 'completed')
? list.filter((r) => requirementDevStatusMap.get(r.id) === 'completed')
: list.filter((r) => r.status === statusFilter);
}
@@ -281,7 +296,7 @@ function RequirementsPageContent() {
list = sortRequirementsByCreatedAt(list, dateSort);
return list;
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -450,22 +465,27 @@ function RequirementsPageContent() {
</div>
) : (
<>
<div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="min-w-[1120px] w-full text-left text-[13px]">
<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="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></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}></th>
<th
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
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">
@@ -473,7 +493,7 @@ function RequirementsPageContent() {
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
</span>
</th>
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className={`${REQUIREMENT_TABLE_HEADER_CELL_CLASS} text-right`}></th>
</tr>
</thead>
<tbody>
@@ -545,13 +565,20 @@ function RequirementsPageContent() {
{/* 所属版本 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{resolveVersionName(req.versionId)}
{(() => {
const versionName = resolveVersionName(req.versionId);
return (
<span className={getRequirementTableTextClass('version')} title={versionName}>
{versionName}
</span>
);
})()}
</td>
{/* 开发状态 */}
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
{(() => {
const devStatus = deriveReqDevStatus(req.id, devTasks);
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]}
@@ -573,13 +600,13 @@ function RequirementsPageContent() {
</td>
{/* 操作 */}
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1 whitespace-nowrap">
<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, devTasks) && (
{(['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 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)]"
>
</button>
@@ -589,23 +616,23 @@ function RequirementsPageContent() {
<>
<button
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
className="h-6 px-2 rounded text-[11px] font-medium text-emerald-600 hover:bg-emerald-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-emerald-600 transition-colors hover:bg-emerald-50"
>
</button>
<button
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
className="h-6 px-2 rounded text-[11px] font-medium text-red-600 hover:bg-red-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-600 transition-colors hover:bg-red-50"
>
</button>
</>
)}
{/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
<button
onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-orange-600 transition-colors hover:bg-orange-50"
>
</button>
@@ -614,7 +641,7 @@ function RequirementsPageContent() {
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
<button
onClick={() => deleteRequirement(req.id)}
className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-50"
>
</button>
@@ -644,10 +671,10 @@ function RequirementsPageContent() {
requirements={requirements}
resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)}
onEdit={canEditRequirement(viewingReq.id, devTasks) ? () => handleEdit(viewingReq) : undefined}
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, devTasks) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
/>
)}

View File

@@ -34,7 +34,8 @@ 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 { buildVersionProgressMap } from '@/lib/version-progress';
import { buildVersionDataScope } 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 {
@@ -139,21 +140,33 @@ export default function VersionDetailPage() {
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const releaseProgressMap = useMemo<Record<string, number>>(
() => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {},
[version, plans, requirements, devTasks, testCases],
const versionScope = useMemo(
() => version
? buildVersionDataScope({
versionId: version.id,
requirements,
plans,
devTasks,
testCases,
bugs,
overtimeRecords: records,
})
: null,
[version, requirements, plans, devTasks, testCases, bugs, records],
);
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
const releaseProgress = versionScope
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
: 0;
const versionReadonly = version ? isVersionReadonly(version.status) : false;
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
useEffect(() => {
if (!version || !plans.length) return;
if (!version || !versionScope || !versionScope.plans.length) return;
if (version.status !== 'planned' && version.status !== 'developing') return;
const today = new Date().toISOString().slice(0, 10);
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
const versionPlans = versionScope.plans.filter((p) => p.startTime <= today);
if (versionPlans.length === 0) return;
let targetStage = '';
for (const p of versionPlans) {
@@ -165,7 +178,7 @@ export default function VersionDetailPage() {
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
}
}, [plans, version, versionId, updateVersion]);
}, [versionScope, version, updateVersion]);
if (!version) {
return (
@@ -189,36 +202,36 @@ export default function VersionDetailPage() {
);
}
const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id);
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
product.versions.forEach((item) => {
const uniqueNames = new Set((item.members ?? []).map((member) => member.name));
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
const scopedVersionData = versionScope!;
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
product.versions.forEach((item) => {
const uniqueNames = new Set((item.members ?? []).map((member) => member.name));
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
});
});
});
const memberRecommendationGroups = recommendVersionMembers({
candidates: memberCandidates,
currentMembers: version.members ?? [],
devTasks,
testCases,
bugs,
overtimeRecords: records,
versionDeadline: version.expectedReleaseDate,
scopeRequirements: recommendationVersionReqs,
scopeDevTasks: recommendationVersionDevTasks,
scopeTestCases: recommendationVersionTestCases,
taskCategories,
historicalStats: memberCandidates.flatMap((member) => {
const projectParticipationCount = currentSystemParticipation.get(member.name);
return typeof projectParticipationCount === 'number'
? [{ name: member.name, projectParticipationCount }]
: [];
}),
});
return recommendVersionMembers({
candidates: memberCandidates,
currentMembers: version.members ?? [],
devTasks,
testCases,
bugs,
overtimeRecords: records,
versionDeadline: version.expectedReleaseDate,
scopeRequirements: scopedVersionData.requirements,
scopeDevTasks: scopedVersionData.devTasks,
scopeTestCases: scopedVersionData.testCases,
taskCategories,
historicalStats: memberCandidates.flatMap((member) => {
const projectParticipationCount = currentSystemParticipation.get(member.name);
return typeof projectParticipationCount === 'number'
? [{ name: member.name, projectParticipationCount }]
: [];
}),
});
})() : [];
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = [];
@@ -231,15 +244,14 @@ export default function VersionDetailPage() {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
// 清理计划任务
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
scopedVersionData.plans.forEach((p) => deletePlan(p.id));
// 清理开发任务
const versionReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id));
scopedVersionData.devTasks.forEach((t) => deleteDevTask(t.id));
// 清理测试用例和Bug
testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id));
bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id));
scopedVersionData.testCases.forEach((c) => deleteTestCase(c.id));
scopedVersionData.bugs.forEach((b) => deleteBug(b.id));
deleteVersion(version.productId, version.id);
router.push('/versions');
}
@@ -304,13 +316,12 @@ export default function VersionDetailPage() {
{activeTab === 'overview' ? (
(() => {
const now = new Date();
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const versionReqIds = new Set(versionReqs.map((r) => r.id));
const versionPlans = plans.filter((p) => p.versionId === version.id);
const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId));
const versionTCs = testCases.filter((c) => c.versionId === version.id);
const versionBugs = bugs.filter((b) => b.versionId === version.id);
const versionOT = records.filter((r) => r.versionId === version.id);
const versionReqs = scopedVersionData.requirements;
const versionPlans = scopedVersionData.plans;
const versionDevTasks = scopedVersionData.devTasks;
const versionTCs = scopedVersionData.testCases;
const versionBugs = scopedVersionData.bugs;
const versionOT = scopedVersionData.overtimeRecords;
const stageEffortMetrics = calcStageEffortMetrics({
plans: versionPlans,
devTasks: versionDevTasks,
@@ -663,7 +674,7 @@ export default function VersionDetailPage() {
{/* 6. 需求变更统计 */}
{(() => {
const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change');
const changeReqs = versionReqs.filter((r) => r.reqType === 'change');
if (changeReqs.length === 0) return null;
// 变更人员排名
@@ -900,7 +911,7 @@ export default function VersionDetailPage() {
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
return (
<PlanTab
plans={plans}
plans={scopedVersionData.plans}
versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
@@ -942,27 +953,32 @@ export default function VersionDetailPage() {
);
})()
) : activeTab === 'tasks' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return (
<DevTaskTab
versionId={version.id}
requirementIds={versionReqs.map((r) => r.id)}
versionDeadline={version.expectedReleaseDate ?? undefined}
readOnly={versionReadonly}
/>
);
})()
<DevTaskTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
versionDeadline={version.expectedReleaseDate ?? undefined}
readOnly={versionReadonly}
versionTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
/>
) : activeTab === 'testcases' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
})()
<TestCaseTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionCases={scopedVersionData.testCases}
versionBugs={scopedVersionData.bugs}
versionDevTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
/>
) : activeTab === 'bugs' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
})()
<BugTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionBugs={scopedVersionData.bugs}
versionTestCases={scopedVersionData.testCases}
/>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
<span className="text-[13px] text-[var(--ink-muted)]"></span>

View File

@@ -13,7 +13,7 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { flattenVersions } from '@/lib/derive';
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
import { aggregateWorkItems, getWorkspacePendingCountByVersion, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
import type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
import { DailyReportPanel } from '@/components/workspace/DailyReportPanel';
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
@@ -96,6 +96,7 @@ export default function WorkspacePage() {
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
);
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
// 构建树:只显示跟自己有关的产品/项目/版本
const tree = useMemo(() => {
@@ -108,14 +109,14 @@ export default function WorkspacePage() {
const prod = productMap.get(v.productName)!;
if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] });
const proj = prod.projects.get(v.projectName)!;
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
const pending = pendingCountByVersion.get(v.id) ?? 0;
if (!proj.versions.find((ver) => ver.id === v.id)) {
proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending });
}
});
return productMap;
}, [allVersions, workItems]);
}, [allVersions, pendingCountByVersion, workItems]);
const filteredItems = useMemo(() => {
let items = workItems;