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

@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import type { DevTask } from './dev-task';
import { canEditRequirement } from './linkage-engine';
import { buildRequirementDevStatusMap, canEditRequirement } from './linkage-engine';
import * as linkageEngine from './linkage-engine';
function task(patch: Partial<DevTask> = {}): DevTask {
@@ -37,3 +37,19 @@ test('active or completed requirement development status cannot be closed', () =
assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false);
assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false);
});
test('buildRequirementDevStatusMap derives every requirement status in one pass', () => {
const statusMap = buildRequirementDevStatusMap([
task({ id: 'task-todo', requirementId: 'req-todo', status: 'todo' }),
task({ id: 'task-active', requirementId: 'req-active', status: 'in_progress' }),
task({ id: 'task-testing', requirementId: 'req-active', status: 'testing' }),
task({ id: 'task-done-1', requirementId: 'req-done', status: 'submitted' }),
task({ id: 'task-done-2', requirementId: 'req-done', status: 'submitted' }),
task({ id: 'task-no-req', requirementId: '', status: 'submitted' }),
]);
assert.equal(statusMap.get('req-todo'), 'todo');
assert.equal(statusMap.get('req-active'), 'developing');
assert.equal(statusMap.get('req-done'), 'completed');
assert.equal(statusMap.has(''), false);
});

View File

@@ -28,6 +28,31 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
return 'todo';
}
export function buildRequirementDevStatusMap(devTasks: DevTask[]): Map<string, ReqDevStatus> {
const stats = new Map<string, { total: number; submitted: number; active: boolean }>();
for (const task of devTasks) {
if (!task.requirementId) continue;
const row = stats.get(task.requirementId) ?? { total: 0, submitted: 0, active: false };
row.total += 1;
if (task.status === 'submitted') row.submitted += 1;
if (task.status === 'in_progress' || task.status === 'testing') row.active = true;
stats.set(task.requirementId, row);
}
const statusMap = new Map<string, ReqDevStatus>();
for (const [requirementId, row] of stats) {
if (row.total > 0 && row.submitted === row.total) {
statusMap.set(requirementId, 'completed');
} else if (row.active) {
statusMap.set(requirementId, 'developing');
} else {
statusMap.set(requirementId, 'todo');
}
}
return statusMap;
}
/**
* 需求是否可编辑
* 规则:开发中(有 in_progress 或 testing 的任务)不可编辑

View File

@@ -3,16 +3,33 @@ import assert from 'node:assert/strict';
import {
REQUIREMENT_TABLE_BADGE_CLASS,
REQUIREMENT_TABLE_CLASS,
REQUIREMENT_TABLE_COLUMN_WIDTHS,
REQUIREMENT_TABLE_CONTAINER_CLASS,
REQUIREMENT_TABLE_HEADER_CELL_CLASS,
getRequirementTableTextClass,
} from './requirement-table-layout';
test('uses fixed table layout without horizontal scrolling for requirement list', () => {
assert.match(REQUIREMENT_TABLE_CLASS, /\btable-fixed\b/);
assert.doesNotMatch(REQUIREMENT_TABLE_CLASS, /\bmin-w-\[/);
assert.doesNotMatch(REQUIREMENT_TABLE_CONTAINER_CLASS, /\boverflow-x-auto\b/);
const totalWidth = REQUIREMENT_TABLE_COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
assert.equal(totalWidth, 100);
});
test('keeps requirement table headers on one line', () => {
assert.match(REQUIREMENT_TABLE_HEADER_CELL_CLASS, /\bwhitespace-nowrap\b/);
});
test('keeps requirement table badges on one line', () => {
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/);
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/);
});
test('clips long requirement source project type and creator cells', () => {
for (const column of ['source', 'project', 'type', 'creator'] as const) {
test('clips long requirement source project type version and creator cells', () => {
for (const column of ['source', 'project', 'type', 'version', 'creator'] as const) {
const className = getRequirementTableTextClass(column);
assert.match(className, /\btruncate\b/);

View File

@@ -1,13 +1,24 @@
export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'creator';
export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'version' | 'creator';
const TEXT_COLUMN_MAX_WIDTH: Record<RequirementTableTextColumn, string> = {
source: 'max-w-[170px]',
project: 'max-w-[120px]',
type: 'max-w-[96px]',
version: 'max-w-[92px]',
creator: 'max-w-[96px]',
};
export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-4 py-3 whitespace-nowrap';
export const REQUIREMENT_TABLE_CONTAINER_CLASS =
'overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]';
export const REQUIREMENT_TABLE_CLASS = 'w-full table-fixed text-left text-[13px]';
export const REQUIREMENT_TABLE_COLUMN_WIDTHS = [6, 15, 14, 7, 6, 6, 5, 6, 7, 6, 8, 14] as const;
export const REQUIREMENT_TABLE_HEADER_CELL_CLASS =
'px-3 py-2.5 whitespace-nowrap text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]';
export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-3 py-3 whitespace-nowrap';
export const REQUIREMENT_TABLE_BADGE_CLASS =
'inline-flex h-6 min-w-[3.25rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-medium';

View File

@@ -90,3 +90,85 @@ test('server data saves include the latest loaded AppData version', async () =>
globalThis.fetch = originalFetch;
}
});
test('server data shares an in-flight load for the same AppData key', async () => {
const originalFetch = globalThis.fetch;
let dataRequests = 0;
let releaseDataResponse!: () => void;
const dataResponseReady = new Promise<void>((resolve) => {
releaseDataResponse = resolve;
});
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/config/ai')) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/test-cases')) {
dataRequests += 1;
await dataResponseReady;
return new Response(JSON.stringify({
key: 'test-cases',
value: [{ id: 'tc-1' }],
version: 'test-cases-version-1',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
}) as typeof fetch;
try {
const first = loadServerData<Array<{ id: string }>>('test-cases');
const second = loadServerData<Array<{ id: string }>>('test-cases');
releaseDataResponse();
assert.deepEqual(await first, [{ id: 'tc-1' }]);
assert.deepEqual(await second, [{ id: 'tc-1' }]);
assert.equal(dataRequests, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('server data reuses a fresh cached load for repeated AppData reads', async () => {
const originalFetch = globalThis.fetch;
let dataRequests = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/config/ai')) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/version-plans')) {
dataRequests += 1;
return new Response(JSON.stringify({
key: 'version-plans',
value: [{ id: 'plan-1' }],
version: 'version-plans-version-1',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
}) as typeof fetch;
try {
const first = await loadServerData<Array<{ id: string }>>('version-plans');
const second = await loadServerData<Array<{ id: string }>>('version-plans');
assert.deepEqual(first, [{ id: 'plan-1' }]);
assert.deepEqual(second, [{ id: 'plan-1' }]);
assert.equal(dataRequests, 1);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -30,6 +30,18 @@ type ServerDataConflictBody<T> = {
};
const serverDataVersions = new Map<ServerDataKey, string | null>();
const serverDataCache = new Map<ServerDataKey, {
value: unknown | null;
version: string | null;
loadedAt: number;
}>();
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
export const SERVER_DATA_CACHE_MS = 30_000;
type LoadServerDataOptions = {
force?: boolean;
maxAgeMs?: number;
};
export class ServerDataConflictError<T = unknown> extends Error {
constructor(
@@ -42,10 +54,36 @@ export class ServerDataConflictError<T = unknown> extends Error {
}
}
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
serverDataVersions.set(key, res.version ?? null);
return res.value;
export async function loadServerData<T>(
key: ServerDataKey,
options: LoadServerDataOptions = {},
): Promise<T | null> {
const maxAgeMs = options.maxAgeMs ?? SERVER_DATA_CACHE_MS;
const cached = serverDataCache.get(key);
if (!options.force && cached && maxAgeMs > 0 && Date.now() - cached.loadedAt <= maxAgeMs) {
return cached.value as T | null;
}
const inFlight = serverDataLoadPromises.get(key);
if (inFlight) return inFlight as Promise<T | null>;
const promise = api.get<ServerDataResponse<T>>(`/data/${key}`)
.then((res) => {
const version = res.version ?? null;
serverDataVersions.set(key, version);
serverDataCache.set(key, {
value: res.value,
version,
loadedAt: Date.now(),
});
return res.value;
})
.finally(() => {
serverDataLoadPromises.delete(key);
});
serverDataLoadPromises.set(key, promise as Promise<unknown | null>);
return promise;
}
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
@@ -56,7 +94,13 @@ export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<v
try {
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
serverDataVersions.set(key, res.version ?? null);
const version = res.version ?? null;
serverDataVersions.set(key, version);
serverDataCache.set(key, {
value,
version,
loadedAt: Date.now(),
});
} catch (error) {
if (
error instanceof ApiRequestError &&

View File

@@ -0,0 +1,171 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { OvertimeRecord } from './overtime';
import type { Requirement } from './requirement';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
function requirement(id: string, versionId: string | undefined): Requirement {
return {
id,
code: id.toUpperCase(),
title: `Requirement ${id}`,
description: '',
productId: 'product-1',
projectId: 'project-1',
versionId,
sourceType: 'internal',
sourceTarget: 'product',
platforms: ['web'],
typeId: 'type-1',
status: 'planned',
priority: 'P1',
effort: 'M',
creator: 'PM',
createdAt: '2026-07-02T09:00:00.000Z',
};
}
function plan(id: string, versionId: string, type: VersionPlan['type']): VersionPlan {
return {
id,
versionId,
type,
title: `Plan ${id}`,
owner: 'PM',
startTime: '2026-07-02T09:00:00.000Z',
endTime: '2026-07-02T18:00:00.000Z',
status: 'pending',
createdAt: '2026-07-02T09:00:00.000Z',
addedBy: 'PM',
};
}
function devTask(id: string, patch: Partial<DevTask>): DevTask {
return {
id,
taskNo: id.toUpperCase(),
versionId: undefined,
requirementId: '',
title: `Task ${id}`,
categoryId: 'frontend',
assigneeId: 'Alice',
priority: 'P1',
expectedStartAt: '',
expectedEndAt: '',
status: 'todo',
isBlocked: false,
createdBy: 'PM',
createdAt: '2026-07-02T09:00:00.000Z',
updatedAt: '2026-07-02T09:00:00.000Z',
...patch,
};
}
function testCase(id: string, versionId: string): TestCase {
return {
id,
caseNo: id.toUpperCase(),
versionId,
title: `Case ${id}`,
categoryId: 'testing',
priority: 'P1',
status: 'pending',
createdBy: 'QA',
createdAt: '2026-07-02T09:00:00.000Z',
updatedAt: '2026-07-02T09:00:00.000Z',
};
}
function bug(id: string, versionId: string): Bug {
return {
id,
bugNo: id.toUpperCase(),
versionId,
testCaseId: 'tc-1',
title: `Bug ${id}`,
description: '',
severity: 'major',
priority: 'P1',
reportedBy: 'QA',
assigneeId: 'Bob',
status: 'open',
createdAt: '2026-07-02T09:00:00.000Z',
updatedAt: '2026-07-02T09:00:00.000Z',
};
}
function overtime(id: string, versionId: string): OvertimeRecord {
return {
id,
projectId: 'project-1',
versionId,
person: 'Alice',
startTime: '2026-07-02T19:00:00.000Z',
endTime: '2026-07-02T21:00:00.000Z',
duration: 2,
reasonId: 'reason-4',
createdAt: '2026-07-02T21:00:00.000Z',
};
}
test('buildVersionDataScope scopes version detail data once', () => {
const scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-1', 'version-1'), requirement('req-2', 'version-2')],
plans: [plan('plan-1', 'version-1', 'research'), plan('plan-2', 'version-1', 'product'), plan('plan-3', 'version-2', 'ui')],
devTasks: [
devTask('dev-direct', { versionId: 'version-1', requirementId: '' }),
devTask('dev-legacy', { requirementId: 'req-1' }),
devTask('dev-other', { versionId: 'version-2', requirementId: 'req-2' }),
],
testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2')],
bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2')],
overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2')],
});
assert.deepEqual(scope.requirementIds, ['req-1']);
assert.deepEqual(scope.plans.map((item) => item.id), ['plan-1', 'plan-2']);
assert.deepEqual(scope.plansByType.research.map((item) => item.id), ['plan-1']);
assert.deepEqual(scope.plansByType.product.map((item) => item.id), ['plan-2']);
assert.deepEqual(scope.devTasks.map((item) => item.id), ['dev-direct', 'dev-legacy']);
assert.deepEqual(scope.testCases.map((item) => item.id), ['tc-1']);
assert.deepEqual(scope.bugs.map((item) => item.id), ['bug-1']);
assert.deepEqual(scope.overtimeRecords.map((item) => item.id), ['ot-1']);
});
test('buildVersionDataScopeMap indexes many versions in one pass', () => {
const scopeMap = buildVersionDataScopeMap({
versionIds: ['version-1', 'version-2', 'version-empty'],
requirements: [
requirement('req-1', 'version-1'),
requirement('req-2', 'version-2'),
requirement('req-outside', 'version-3'),
],
plans: [
plan('plan-1', 'version-1', 'research'),
plan('plan-2', 'version-2', 'ui'),
plan('plan-outside', 'version-3', 'product'),
],
devTasks: [
devTask('dev-direct', { versionId: 'version-2', requirementId: 'req-1' }),
devTask('dev-legacy', { requirementId: 'req-1' }),
devTask('dev-outside', { requirementId: 'req-outside' }),
],
testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2'), testCase('tc-outside', 'version-3')],
bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2'), bug('bug-outside', 'version-3')],
overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2'), overtime('ot-outside', 'version-3')],
});
assert.deepEqual(scopeMap['version-1'].requirements.map((item) => item.id), ['req-1']);
assert.deepEqual(scopeMap['version-1'].devTasks.map((item) => item.id), ['dev-legacy']);
assert.deepEqual(scopeMap['version-2'].requirements.map((item) => item.id), ['req-2']);
assert.deepEqual(scopeMap['version-2'].devTasks.map((item) => item.id), ['dev-direct']);
assert.deepEqual(scopeMap['version-2'].plansByType.ui.map((item) => item.id), ['plan-2']);
assert.deepEqual(scopeMap['version-empty'].requirements, []);
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
});

View File

@@ -0,0 +1,144 @@
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { OvertimeRecord } from './overtime';
import type { Requirement } from './requirement';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
export interface VersionDataScope {
requirements: Requirement[];
requirementIds: string[];
requirementIdSet: Set<string>;
plans: VersionPlan[];
plansByType: Record<VersionPlan['type'], VersionPlan[]>;
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords: OvertimeRecord[];
}
interface VersionDataScopeInput {
versionId: string;
requirements: Requirement[];
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords?: OvertimeRecord[];
}
interface VersionDataScopeMapInput extends Omit<VersionDataScopeInput, 'versionId'> {
versionIds: string[];
}
export function buildVersionDataScope(input: VersionDataScopeInput): VersionDataScope {
const {
versionId,
requirements,
plans,
devTasks,
testCases,
bugs,
overtimeRecords = [],
} = input;
const scopedRequirements: Requirement[] = [];
const requirementIds: string[] = [];
const requirementIdSet = new Set<string>();
for (const requirement of requirements) {
if (requirement.versionId !== versionId) continue;
scopedRequirements.push(requirement);
requirementIds.push(requirement.id);
requirementIdSet.add(requirement.id);
}
const scopedPlans: VersionPlan[] = [];
const plansByType: Record<VersionPlan['type'], VersionPlan[]> = {
research: [],
product: [],
ui: [],
};
for (const plan of plans) {
if (plan.versionId !== versionId) continue;
scopedPlans.push(plan);
plansByType[plan.type].push(plan);
}
return {
requirements: scopedRequirements,
requirementIds,
requirementIdSet,
plans: scopedPlans,
plansByType,
devTasks: devTasks.filter((task) => task.versionId === versionId || (!task.versionId && requirementIdSet.has(task.requirementId))),
testCases: testCases.filter((testCase) => testCase.versionId === versionId),
bugs: bugs.filter((bug) => bug.versionId === versionId),
overtimeRecords: overtimeRecords.filter((record) => record.versionId === versionId),
};
}
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
const scopes: Record<string, VersionDataScope> = {};
const targetVersionIds = new Set(input.versionIds);
for (const versionId of input.versionIds) {
scopes[versionId] = createEmptyScope();
}
const requirementVersionMap = new Map<string, string>();
for (const requirement of input.requirements) {
const versionId = requirement.versionId;
if (!versionId) continue;
requirementVersionMap.set(requirement.id, versionId);
const scope = scopes[versionId];
if (!scope) continue;
scope.requirements.push(requirement);
scope.requirementIds.push(requirement.id);
scope.requirementIdSet.add(requirement.id);
}
for (const plan of input.plans) {
const scope = scopes[plan.versionId];
if (!scope) continue;
scope.plans.push(plan);
scope.plansByType[plan.type].push(plan);
}
for (const task of input.devTasks) {
const versionId = task.versionId || requirementVersionMap.get(task.requirementId);
if (!versionId || !targetVersionIds.has(versionId)) continue;
scopes[versionId]?.devTasks.push(task);
}
for (const testCase of input.testCases) {
scopes[testCase.versionId]?.testCases.push(testCase);
}
for (const bug of input.bugs) {
scopes[bug.versionId]?.bugs.push(bug);
}
for (const record of input.overtimeRecords ?? []) {
if (!record.versionId) continue;
scopes[record.versionId]?.overtimeRecords.push(record);
}
return scopes;
}
function createEmptyScope(): VersionDataScope {
return {
requirements: [],
requirementIds: [],
requirementIdSet: new Set<string>(),
plans: [],
plansByType: {
research: [],
product: [],
ui: [],
},
devTasks: [],
testCases: [],
bugs: [],
overtimeRecords: [],
};
}

View File

@@ -23,6 +23,14 @@ export function calcVersionProgress(
const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
const vTestCases = testCases.filter((c) => c.versionId === versionId);
return calcScopedVersionProgress(vPlans, vDevTasks, vTestCases);
}
export function calcScopedVersionProgress(
vPlans: VersionPlan[],
vDevTasks: DevTask[],
vTestCases: TestCase[],
): number {
const segments: number[] = [];
const researchPlans = vPlans.filter((p) => p.type === 'research');
@@ -93,9 +101,40 @@ export function buildVersionProgressMap(
devTasks: DevTask[],
testCases: TestCase[],
): Record<string, number> {
const plansByVersion = groupByVersionId(plans);
const requirementVersionMap = new Map<string, string>();
for (const requirement of requirements) {
if (!requirement.versionId) continue;
requirementVersionMap.set(requirement.id, requirement.versionId);
}
const devTasksByVersion = new Map<string, DevTask[]>();
for (const task of devTasks) {
const versionId = task.versionId || requirementVersionMap.get(task.requirementId);
if (!versionId) continue;
const items = devTasksByVersion.get(versionId) ?? [];
items.push(task);
devTasksByVersion.set(versionId, items);
}
const testCasesByVersion = groupByVersionId(testCases);
const map: Record<string, number> = {};
for (const v of versions) {
map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases);
map[v.id] = calcScopedVersionProgress(
plansByVersion.get(v.id) ?? [],
devTasksByVersion.get(v.id) ?? [],
testCasesByVersion.get(v.id) ?? [],
);
}
return map;
}
function groupByVersionId<T extends { versionId: string }>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const group = map.get(item.versionId) ?? [];
group.push(item);
map.set(item.versionId, group);
}
return map;
}

View File

@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine';
import { aggregateWorkItems, getWorkspacePendingCount, getWorkspacePendingCountByVersion } from './workspace-engine';
import type { DevTask } from './dev-task';
test('getWorkspacePendingCount counts unfinished work items only', () => {
@@ -13,6 +13,51 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
assert.equal(count, 2);
});
test('getWorkspacePendingCountByVersion counts unfinished work items in one pass', () => {
const counts = getWorkspacePendingCountByVersion([
{ versionId: 'ver-1', completed: false },
{ versionId: 'ver-1', completed: true },
{ versionId: 'ver-2', completed: false },
{ versionId: '', completed: false },
]);
assert.equal(counts.get('ver-1'), 1);
assert.equal(counts.get('ver-2'), 1);
assert.equal(counts.has(''), false);
});
test('aggregateWorkItems returns no items before the current user is known', () => {
const unassignedTask = {
id: 'dev-unassigned',
taskNo: 'DEV-002',
versionId: 'ver-1',
requirementId: '',
title: 'Unassigned task',
categoryId: 'frontend',
assigneeId: '',
priority: 'P1',
expectedStartAt: '',
expectedEndAt: '',
status: 'todo',
isBlocked: false,
createdBy: 'AI',
createdAt: '2026-06-29T09:00:00.000Z',
updatedAt: '2026-06-29T09:00:00.000Z',
} as DevTask;
const items = aggregateWorkItems(
'',
[],
[unassignedTask],
[],
[],
new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]),
new Map(),
);
assert.deepEqual(items, []);
});
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
const task = {
id: 'dev-no-req',

View File

@@ -37,6 +37,7 @@ export function aggregateWorkItems(
requirementVersionMap: Map<string, string>,
): WorkItem[] {
const items: WorkItem[] = [];
if (!userName.trim()) return items;
// Plans: owner === userName
plans.filter((p) => p.owner === userName).forEach((p) => {
@@ -127,6 +128,17 @@ export function getWorkspacePendingCount(items: ReadonlyArray<Pick<WorkItem, 'co
return items.reduce((sum, item) => sum + (item.completed ? 0 : 1), 0);
}
export function getWorkspacePendingCountByVersion(
items: ReadonlyArray<Pick<WorkItem, 'versionId' | 'completed'>>,
): Map<string, number> {
const map = new Map<string, number>();
for (const item of items) {
if (!item.versionId || item.completed) continue;
map.set(item.versionId, (map.get(item.versionId) ?? 0) + 1);
}
return map;
}
export const WORK_ITEM_TYPE_LABEL: Record<WorkItemType, string> = {
plan_research: '调研',
plan_product: '产品方案',