perf(web): 优化大数据量页面切换与聚合性能
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user