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

@@ -6,9 +6,10 @@ import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module';
import { MigrationModule } from './modules/migration/migration.module';
import { V22QueryModule } from './modules/v22-query/v22-query.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule],
controllers: [],
providers: [],
})

View File

@@ -55,6 +55,31 @@ function buildAppData(): Record<string, any> {
},
],
},
members: {
members: [
{
id: 'member-pm',
name: 'Product manager',
username: 'pm',
email: '',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'member-dev',
name: 'Developer',
username: 'dev',
email: 'dev@example.com',
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'member-test',
name: 'Tester',
username: 'test',
email: '',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
},
'task-categories': [
{
id: 'cat-fe',
@@ -261,6 +286,11 @@ describe('mapAppDataToV22Rows', () => {
releaseDate: '2026-02-01',
}),
]);
expect(result.users).toEqual([
expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }),
expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }),
expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }),
]);
expect(result.requirements).toEqual([
expect.objectContaining({

View File

@@ -34,6 +34,15 @@ interface VersionRow {
updatedAt?: string;
}
interface UserRow {
id: string;
email: string;
name: string;
avatar?: string;
createdAt?: string;
updatedAt?: string;
}
interface RequirementRow {
id: string;
productId: string;
@@ -256,6 +265,7 @@ export interface V22MappedRows {
products: ProductRow[];
projects: ProjectRow[];
versions: VersionRow[];
users: UserRow[];
requirements: RequirementRow[];
taskCategories: TaskCategoryRow[];
versionPlans: VersionPlanRow[];
@@ -277,6 +287,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
const products: ProductRow[] = [];
const projects: ProjectRow[] = [];
const versions: VersionRow[] = [];
const users: UserRow[] = [];
const requirements: RequirementRow[] = [];
const taskCategories: TaskCategoryRow[] = [];
const versionPlans: VersionPlanRow[] = [];
@@ -346,6 +357,21 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
}
}
const nextEmail = uniqueEmailFactory();
for (const [index, member] of readNestedArray(appData.members, 'members').entries()) {
const id = stringField(member, 'id') ?? `member-${index + 1}`;
const username = stringField(member, 'username') ?? id;
const email = normalizeMemberEmail(stringField(member, 'email'), username, id);
users.push({
id,
email: nextEmail(email),
name: stringField(member, 'name') ?? username,
avatar: stringField(member, 'avatar'),
createdAt: stringField(member, 'createdAt'),
updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'),
});
}
const defaultProductId = products[0]?.id;
const requirementCode = scopedCodeFactory('REQ');
for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) {
@@ -705,6 +731,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
products,
projects,
versions,
users,
requirements,
taskCategories,
versionPlans,
@@ -809,6 +836,34 @@ function scopedCodeFactory(prefix: string) {
};
}
function uniqueEmailFactory() {
const used = new Set<string>();
return (email: string): string => {
const normalized = email.trim().toLowerCase();
if (!used.has(normalized)) {
used.add(normalized);
return normalized;
}
const atIndex = normalized.indexOf('@');
const name = atIndex >= 0 ? normalized.slice(0, atIndex) : normalized;
const domain = atIndex >= 0 ? normalized.slice(atIndex + 1) : 'local.ftb';
let suffix = 2;
let next = `${name}+${suffix}@${domain}`;
while (used.has(next)) {
suffix++;
next = `${name}+${suffix}@${domain}`;
}
used.add(next);
return next;
};
}
function normalizeMemberEmail(email: string | undefined, username: string, id: string): string {
if (email?.includes('@')) return email;
const base = email || username || id;
return `${base.trim().toLowerCase()}@local.ftb`;
}
function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined {
const explicitProjectId = stringField(version, 'projectId');
if (explicitProjectId) return explicitProjectId;

View File

@@ -15,6 +15,20 @@ describe('AppDataV22MigrationService', () => {
},
],
},
{
key: 'members',
value: {
members: [
{
id: 'member-pm',
name: 'Product manager',
username: 'pm',
email: '',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
},
},
{
key: 'requirements',
value: {
@@ -26,6 +40,7 @@ describe('AppDataV22MigrationService', () => {
projectId: 'project-1',
versionId: 'version-1',
title: 'Customer import',
creator: 'member-pm',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
@@ -64,10 +79,98 @@ describe('AppDataV22MigrationService', () => {
products: 1,
projects: 1,
versions: 1,
users: 1,
requirements: 1,
devTasks: 1,
}),
);
expect(preview.skipped).toEqual([]);
});
it('imports mapped rows in foreign-key-safe order with duplicate protection', async () => {
const calls: string[] = [];
const delegate = (name: string) => ({
createMany: jest.fn().mockImplementation(async () => {
calls.push(name);
return { count: 1 };
}),
});
const tx = {
user: delegate('user'),
product: delegate('product'),
project: delegate('project'),
version: delegate('version'),
taskCategory: delegate('taskCategory'),
requirement: delegate('requirement'),
versionPlan: delegate('versionPlan'),
devTask: delegate('devTask'),
testCase: delegate('testCase'),
bug: delegate('bug'),
workActivity: delegate('workActivity'),
taskWorklog: delegate('taskWorklog'),
overtimeRecord: delegate('overtimeRecord'),
xiaobaoRiskSnapshot: delegate('xiaobaoRiskSnapshot'),
xiaobaoRiskInsight: delegate('xiaobaoRiskInsight'),
xiaobaoRiskSummary: delegate('xiaobaoRiskSummary'),
};
const prisma = {
$transaction: jest.fn().mockImplementation(async (callback: (client: typeof tx) => Promise<unknown>) => callback(tx)),
appData: { findMany: jest.fn() },
};
const service = new AppDataV22MigrationService(prisma as any);
const mapped = service.mapSnapshot({
'products-overview': [
{
id: 'product-1',
name: 'FTB',
projects: [{ id: 'project-1', name: 'CRM', description: '', createdAt: '2026-01-01T00:00:00.000Z' }],
versions: [{ id: 'version-1', name: 'CRM V1.0', createdAt: '2026-01-01T00:00:00.000Z' }],
},
],
members: {
members: [{ id: 'member-pm', name: 'Product manager', username: 'pm', email: '', createdAt: '2026-01-01' }],
},
'task-categories': [
{
id: 'cat-fe',
code: 'frontend_development',
name: 'Frontend development',
group: 'development',
isSystem: true,
},
],
requirements: {
requirements: [
{
id: 'req-1',
code: 'REQ-001',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
title: 'Customer import',
creator: 'member-pm',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
},
});
const result = await service.importMappedRows(mapped);
expect(result.readyToImport).toBe(true);
expect(result.inserted).toEqual(expect.objectContaining({ users: 1, products: 1, requirements: 1 }));
expect(calls.slice(0, 6)).toEqual(['user', 'product', 'project', 'version', 'taskCategory', 'requirement']);
expect(tx.user.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
id: 'member-pm',
email: 'pm@local.ftb',
createdAt: '2026-01-01T00:00:00.000Z',
})],
skipDuplicates: true,
});
expect(tx.requirement.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ creatorId: 'member-pm' })],
skipDuplicates: true,
});
});
});

View File

@@ -1,14 +1,15 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS } from '../data/data-keys';
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
const COUNT_KEYS = [
'users',
'products',
'projects',
'versions',
'requirements',
'taskCategories',
'requirements',
'versionPlans',
'devTasks',
'testCases',
@@ -23,6 +24,72 @@ const COUNT_KEYS = [
] as const;
type CountKey = (typeof COUNT_KEYS)[number];
type DelegateName =
| 'user'
| 'product'
| 'project'
| 'version'
| 'taskCategory'
| 'requirement'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'workActivity'
| 'taskWorklog'
| 'overtimeRecord'
| 'xiaobaoRiskSnapshot'
| 'xiaobaoRiskInsight'
| 'xiaobaoRiskSummary';
type CreateManyDelegate = {
createMany(args: { data: unknown[]; skipDuplicates: boolean }): Promise<{ count: number }>;
};
type MigrationTransaction = Record<DelegateName, CreateManyDelegate>;
const DELEGATE_BY_COUNT_KEY: Record<CountKey, DelegateName> = {
users: 'user',
products: 'product',
projects: 'project',
versions: 'version',
taskCategories: 'taskCategory',
requirements: 'requirement',
versionPlans: 'versionPlan',
devTasks: 'devTask',
testCases: 'testCase',
bugs: 'bug',
workActivities: 'workActivity',
taskWorklogs: 'taskWorklog',
overtimeRecords: 'overtimeRecord',
xiaobaoRiskSnapshots: 'xiaobaoRiskSnapshot',
xiaobaoRiskInsights: 'xiaobaoRiskInsight',
xiaobaoRiskSummaries: 'xiaobaoRiskSummary',
aiLogs: 'user',
};
const DATE_TIME_KEYS = new Set([
'createdAt',
'updatedAt',
'releaseDate',
'expectedStartAt',
'expectedEndAt',
'actualStartAt',
'completedAt',
'startDate',
'plannedTestAt',
'plannedEndAt',
'startedAt',
'plannedFixAt',
'resolvedAt',
'closedAt',
'startAt',
'endAt',
'occurredAt',
'aiDraftAt',
'recomputedAt',
'forecastReleaseDate',
]);
export interface AppDataV22MigrationPreview {
readyToImport: boolean;
@@ -30,6 +97,10 @@ export interface AppDataV22MigrationPreview {
skipped: V22MappedRows['skipped'];
}
export interface AppDataV22MigrationImportResult extends AppDataV22MigrationPreview {
inserted: Record<CountKey, number>;
}
@Injectable()
export class AppDataV22MigrationService {
constructor(private readonly prisma: PrismaService) {}
@@ -54,8 +125,65 @@ export class AppDataV22MigrationService {
skipped: mapped.skipped,
};
}
async importCurrentAppData(): Promise<AppDataV22MigrationImportResult> {
return this.importMappedRows(this.mapSnapshot(await this.loadSnapshot()));
}
async importMappedRows(mapped: V22MappedRows): Promise<AppDataV22MigrationImportResult> {
if (mapped.skipped.length > 0) {
throw new BadRequestException({
code: 'APP_DATA_V22_MIGRATION_HAS_SKIPPED_ROWS',
message: 'Resolve skipped AppData rows before importing V2.2 relation tables.',
skipped: mapped.skipped,
});
}
const inserted = await this.prisma.$transaction(async (tx) => {
const client = tx as unknown as MigrationTransaction;
const result = emptyCounts();
for (const key of COUNT_KEYS) {
if (key === 'aiLogs') continue;
result[key] = await createMany(client[DELEGATE_BY_COUNT_KEY[key]], mapped[key]);
}
return result;
});
return {
readyToImport: true,
counts: countRows(mapped),
skipped: [],
inserted,
};
}
}
function countRows(mapped: V22MappedRows): Record<CountKey, number> {
return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as Record<CountKey, number>;
}
function emptyCounts(): Record<CountKey, number> {
return Object.fromEntries(COUNT_KEYS.map((key) => [key, 0])) as Record<CountKey, number>;
}
async function createMany(delegate: CreateManyDelegate, data: unknown[]): Promise<number> {
if (data.length === 0) return 0;
const result = await delegate.createMany({ data: data.map(normalizeDateTimeFields), skipDuplicates: true });
return result.count;
}
function normalizeDateTimeFields(row: unknown): unknown {
if (typeof row !== 'object' || row === null || Array.isArray(row)) return row;
return Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
DATE_TIME_KEYS.has(key) ? normalizeDateTimeValue(value) : value,
]),
);
}
function normalizeDateTimeValue(value: unknown): unknown {
if (typeof value !== 'string') return value;
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
return value;
}

View File

@@ -0,0 +1,45 @@
import { V22QueryController } from './v22-query.controller';
describe('V22QueryController', () => {
it('forwards V2.2 hot-path requests to the query service', async () => {
const service = {
getVersionDetailData: jest.fn().mockResolvedValue({ version: { id: 'version-1' } }),
listRequirements: jest.fn().mockResolvedValue({ items: [], nextCursor: undefined }),
getWorkspaceData: jest.fn().mockResolvedValue({ devTasks: [] }),
getXiaobaoWarnings: jest.fn().mockResolvedValue([]),
};
const controller = new V22QueryController(service as any);
await controller.getVersionDetailData('version-1');
await controller.listRequirements(
'product-1',
'project-1',
undefined,
'adopted',
'P1',
'feature',
'login',
'created_at_asc',
'req-2',
'50',
);
await controller.getWorkspaceData('member-1');
await controller.getXiaobaoWarnings('member-1', 'false');
expect(service.getVersionDetailData).toHaveBeenCalledWith('version-1');
expect(service.listRequirements).toHaveBeenCalledWith({
productId: 'product-1',
projectId: 'project-1',
versionId: undefined,
status: 'adopted',
priority: 'P1',
type: 'feature',
q: 'login',
sort: 'created_at_asc',
cursor: 'req-2',
limit: '50',
});
expect(service.getWorkspaceData).toHaveBeenCalledWith('member-1');
expect(service.getXiaobaoWarnings).toHaveBeenCalledWith({ userId: 'member-1', manager: 'false' });
});
});

View File

@@ -0,0 +1,52 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { V22QueryService } from './v22-query.service';
@Controller('v2.2')
export class V22QueryController {
constructor(private readonly v22QueryService: V22QueryService) {}
@Get('versions/:versionId/detail-data')
getVersionDetailData(@Param('versionId') versionId: string) {
return this.v22QueryService.getVersionDetailData(versionId);
}
@Get('requirements')
listRequirements(
@Query('productId') productId?: string,
@Query('projectId') projectId?: string,
@Query('versionId') versionId?: string,
@Query('status') status?: string,
@Query('priority') priority?: string,
@Query('type') type?: string,
@Query('q') q?: string,
@Query('sort') sort?: string,
@Query('cursor') cursor?: string,
@Query('limit') limit?: string,
) {
return this.v22QueryService.listRequirements({
productId,
projectId,
versionId,
status,
priority,
type,
q,
sort,
cursor,
limit,
});
}
@Get('workspace')
getWorkspaceData(@Query('userId') userId: string) {
return this.v22QueryService.getWorkspaceData(userId);
}
@Get('xiaobao-warning')
getXiaobaoWarnings(
@Query('userId') userId?: string,
@Query('manager') manager?: string,
) {
return this.v22QueryService.getXiaobaoWarnings({ userId, manager });
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { V22QueryController } from './v22-query.controller';
import { V22QueryService } from './v22-query.service';
@Module({
controllers: [V22QueryController],
providers: [V22QueryService],
})
export class V22QueryModule {}

View File

@@ -0,0 +1,157 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { V22QueryService } from './v22-query.service';
function buildPrismaMock() {
return {
version: {
findUnique: jest.fn(),
},
requirement: {
findMany: jest.fn(),
},
versionPlan: {
findMany: jest.fn(),
},
devTask: {
findMany: jest.fn(),
},
testCase: {
findMany: jest.fn(),
},
bug: {
findMany: jest.fn(),
},
xiaobaoRiskSummary: {
findMany: jest.fn(),
},
};
}
describe('V22QueryService', () => {
it('loads version detail data through version-scoped relation queries', async () => {
const prisma = buildPrismaMock();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.requirement.findMany.mockResolvedValue([{ id: 'req-1' }]);
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'plan-1' }]);
prisma.devTask.findMany.mockResolvedValue([{ id: 'dev-1' }]);
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-1' }]);
prisma.bug.findMany.mockResolvedValue([{ id: 'bug-1' }]);
const service = new V22QueryService(prisma as any);
const result = await service.getVersionDetailData('version-1');
expect(result.requirements).toEqual([{ id: 'req-1' }]);
expect(result.devTasks).toEqual([{ id: 'dev-1' }]);
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
where: { versionId: 'version-1' },
orderBy: { createdAt: 'desc' },
});
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
where: { versionId: 'version-1' },
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
});
});
it('rejects missing productId for requirement pool queries to avoid full-table scans', async () => {
const service = new V22QueryService(buildPrismaMock() as any);
await expect(service.listRequirements({})).rejects.toBeInstanceOf(BadRequestException);
});
it('paginates requirement pool queries by product partition key and composite cursor', async () => {
const prisma = buildPrismaMock();
prisma.requirement.findMany.mockResolvedValue([{ id: 'req-2' }, { id: 'req-1' }]);
const service = new V22QueryService(prisma as any);
const result = await service.listRequirements({
productId: 'product-1',
projectId: 'project-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
q: 'login',
sort: 'created_at_asc',
cursor: 'req-3',
limit: '1',
});
expect(result).toEqual({ items: [{ id: 'req-2' }], nextCursor: 'req-1' });
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
where: {
productId: 'product-1',
projectId: 'project-1',
status: 'adopted',
priority: 1,
type: 'feature',
OR: [
{ code: { contains: 'login', mode: 'insensitive' } },
{ title: { contains: 'login', mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'asc' },
take: 2,
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
skip: 1,
});
});
it('aggregates workspace rows by current user without scanning unrelated assignees', async () => {
const prisma = buildPrismaMock();
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'plan-1' }]);
prisma.devTask.findMany.mockResolvedValue([{ id: 'dev-1' }]);
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-1' }]);
prisma.bug.findMany.mockResolvedValue([{ id: 'bug-1' }]);
const service = new V22QueryService(prisma as any);
const result = await service.getWorkspaceData('member-1');
expect(result).toEqual({
versionPlans: [{ id: 'plan-1' }],
devTasks: [{ id: 'dev-1' }],
testCases: [{ id: 'tc-1' }],
bugs: [{ id: 'bug-1' }],
});
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
where: { assigneeId: 'member-1', status: { not: 'submitted' } },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
});
expect(prisma.bug.findMany).toHaveBeenCalledWith({
where: { assigneeId: 'member-1', status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
});
});
it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => {
const prisma = buildPrismaMock();
prisma.versionPlan.findMany.mockResolvedValue([{ versionId: 'version-1' }]);
prisma.devTask.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]);
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]);
const service = new V22QueryService(prisma as any);
const userResult = await service.getXiaobaoWarnings({ userId: 'member-1' });
await service.getXiaobaoWarnings({ manager: 'true' });
expect(userResult).toEqual([{ versionId: 'version-2', riskScore: 70 }]);
expect(prisma.xiaobaoRiskSummary.findMany).toHaveBeenNthCalledWith(1, {
where: {
versionId: { in: ['version-1', 'version-2', 'version-3'] },
riskLevel: { not: 'on_track' },
},
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
expect(prisma.xiaobaoRiskSummary.findMany).toHaveBeenNthCalledWith(2, {
where: { riskLevel: { not: 'on_track' } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
});
it('throws when a version detail query targets a missing version', async () => {
const prisma = buildPrismaMock();
prisma.version.findUnique.mockResolvedValue(null);
const service = new V22QueryService(prisma as any);
await expect(service.getVersionDetailData('missing-version')).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -0,0 +1,185 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
interface RequirementQuery {
productId?: string;
projectId?: string;
versionId?: string;
status?: string;
priority?: string;
type?: string;
q?: string;
sort?: string;
cursor?: string;
limit?: string;
}
interface XiaobaoWarningQuery {
userId?: string;
manager?: string;
}
@Injectable()
export class V22QueryService {
constructor(private readonly prisma: PrismaService) {}
async getVersionDetailData(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('Version not found');
const [requirements, versionPlans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.requirement.findMany({
where: { versionId },
orderBy: { createdAt: 'desc' },
}),
this.prisma.versionPlan.findMany({
where: { versionId },
orderBy: [{ type: 'asc' }, { createdAt: 'desc' }],
}),
this.prisma.devTask.findMany({
where: { versionId },
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
}),
this.prisma.testCase.findMany({
where: { versionId },
orderBy: [{ roundNo: 'desc' }, { status: 'asc' }, { updatedAt: 'desc' }],
}),
this.prisma.bug.findMany({
where: { versionId },
orderBy: [{ status: 'asc' }, { priority: 'asc' }, { updatedAt: 'desc' }],
}),
]);
return { version, requirements, versionPlans, devTasks, testCases, bugs };
}
async listRequirements(query: RequirementQuery) {
const productId = query.productId?.trim();
if (!productId) {
throw new BadRequestException('productId is required for requirement pool queries');
}
const limit = parseLimit(query.limit);
const priority = parsePriority(query.priority);
const search = query.q?.trim();
const sortDirection = query.sort === 'created_at_asc' ? 'asc' : 'desc';
const rows = await this.prisma.requirement.findMany({
where: {
productId,
...(query.projectId?.trim() ? { projectId: query.projectId.trim() } : {}),
...(query.versionId?.trim() ? { versionId: query.versionId.trim() } : {}),
...(query.status?.trim() ? { status: query.status.trim() } : {}),
...(priority !== undefined ? { priority } : {}),
...(query.type?.trim() ? { type: query.type.trim() } : {}),
...(search
? {
OR: [
{ code: { contains: search, mode: 'insensitive' as const } },
{ title: { contains: search, mode: 'insensitive' as const } },
],
}
: {}),
},
orderBy: { createdAt: sortDirection },
take: limit + 1,
...(query.cursor
? {
cursor: { id_productId: { id: query.cursor, productId } },
skip: 1,
}
: {}),
});
const hasNext = rows.length > limit;
return {
items: hasNext ? rows.slice(0, limit) : rows,
nextCursor: hasNext ? rows[limit]?.id : undefined,
};
}
async getWorkspaceData(userId: string) {
const normalizedUserId = userId.trim();
if (!normalizedUserId) throw new BadRequestException('userId is required');
const [versionPlans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ownerId: normalizedUserId, status: { not: 'completed' } },
orderBy: [{ expectedEndAt: 'asc' }, { updatedAt: 'desc' }],
}),
this.prisma.devTask.findMany({
where: { assigneeId: normalizedUserId, status: { not: 'submitted' } },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
}),
this.prisma.testCase.findMany({
where: { assigneeId: normalizedUserId, status: { notIn: ['passed', 'failed', 'blocked'] } },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
}),
this.prisma.bug.findMany({
where: { assigneeId: normalizedUserId, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }],
}),
]);
return { versionPlans, devTasks, testCases, bugs };
}
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
if (query.manager === 'true') {
return this.prisma.xiaobaoRiskSummary.findMany({
where: { riskLevel: { not: 'on_track' } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
}
const userId = query.userId?.trim();
if (!userId) throw new BadRequestException('userId is required');
const versionIds = await this.getUserOwnedVersionIds(userId);
if (versionIds.length === 0) return [];
return this.prisma.xiaobaoRiskSummary.findMany({
where: {
versionId: { in: versionIds },
riskLevel: { not: 'on_track' },
},
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
}
private async getUserOwnedVersionIds(userId: string): Promise<string[]> {
const [plans, devTasks, testCases, bugs] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { ownerId: userId, status: { not: 'completed' } },
select: { versionId: true },
}),
this.prisma.devTask.findMany({
where: { assigneeId: userId, status: { not: 'submitted' } },
select: { versionId: true },
}),
this.prisma.testCase.findMany({
where: { assigneeId: userId, status: { notIn: ['passed', 'failed', 'blocked'] } },
select: { versionId: true },
}),
this.prisma.bug.findMany({
where: { assigneeId: userId, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
select: { versionId: true },
}),
]);
return Array.from(new Set([...plans, ...devTasks, ...testCases, ...bugs].map((item) => item.versionId)));
}
}
function parseLimit(raw?: string): number {
const parsed = raw ? Number(raw) : 50;
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(200, Math.floor(parsed)));
}
function parsePriority(raw?: string): number | undefined {
const normalized = raw?.trim().toUpperCase();
if (!normalized) return undefined;
const prefixed = /^P([0-4])$/.exec(normalized);
if (prefixed) return Number(prefixed[1]);
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return undefined;
return Math.max(0, Math.min(4, Math.floor(parsed)));
}

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>

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
@@ -10,6 +10,8 @@ import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { aggregateWorkItems } from '@/lib/workspace-engine';
import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api';
import { selectWorkspaceCollections } from '@/lib/workspace-v22-source';
export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolean } = {}) {
const { overview, fetchOverview } = useProductStore();
@@ -19,15 +21,60 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const user = useAuthStore((s) => s.user);
const [v22WorkspaceData, setV22WorkspaceData] = useState<V22WorkspaceData | null>(null);
const [v22WorkspaceLoaded, setV22WorkspaceLoaded] = useState(false);
const [v22WorkspaceFailed, setV22WorkspaceFailed] = useState(false);
useEffect(() => { if (autoFetch) fetchOverview(); }, [autoFetch, fetchOverview]);
useEffect(() => { if (autoFetch) fetchPlans(); }, [autoFetch, fetchPlans]);
useEffect(() => { if (autoFetch) fetchRequirements(); }, [autoFetch, fetchRequirements]);
useEffect(() => { if (autoFetch) fetchTasks(); }, [autoFetch, fetchTasks]);
useEffect(() => { if (autoFetch) fetchTestCases(); }, [autoFetch, fetchTestCases]);
useEffect(() => { if (autoFetch) fetchBugs(); }, [autoFetch, fetchBugs]);
const userName = user?.name ?? '';
const workspaceUserKey = userName || user?.id || '';
useEffect(() => {
if (!autoFetch || !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;
};
}, [autoFetch, workspaceUserKey]);
useEffect(() => {
if (!autoFetch || !workspaceUserKey.trim() || !v22WorkspaceFailed) return;
void fetchPlans();
void fetchRequirements();
void fetchTasks();
void fetchTestCases();
void fetchBugs();
}, [
autoFetch,
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
v22WorkspaceFailed,
workspaceUserKey,
]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionMap = useMemo(() => {
@@ -51,9 +98,27 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
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],
);
return {
@@ -62,8 +127,8 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
versionMap,
requirementVersionMap,
workItems,
devTasks,
testCases,
bugs,
devTasks: workspaceCollections.devTasks,
testCases: workspaceCollections.testCases,
bugs: workspaceCollections.bugs,
};
}

View File

@@ -13,7 +13,14 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { flattenVersions } from '@/lib/derive';
import { loadV22XiaobaoWarnings, type V22XiaobaoWarningSummary } from '@/lib/v22-api';
import { buildVersionDataScopeMap } from '@/lib/version-data-scope';
import {
buildXiaobaoRisksFromV22Summaries,
filterV22XiaobaoSummariesForVisibleVersions,
shouldLoadXiaobaoAppDataFallback,
type V22XiaobaoSummaryLoadState,
} from '@/lib/xiaobao-v22-summary';
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
@@ -42,16 +49,46 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
finishInsightUpdate,
} = useXiaobaoRiskStore();
const [calculationNow] = useState(() => new Date());
const [v22SummaryState, setV22SummaryState] = useState<V22XiaobaoSummaryLoadState>('idle');
const [v22Summaries, setV22Summaries] = useState<V22XiaobaoWarningSummary[]>([]);
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
const v22UserId = user?.name || user?.id || '';
const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchActivities(); }, [fetchActivities]);
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
useEffect(() => {
if (!canManage && !v22UserId) {
setV22SummaryState('idle');
setV22Summaries([]);
return;
}
let ignore = false;
setV22SummaryState('loading');
loadV22XiaobaoWarnings({
userId: v22UserId || undefined,
manager: canManage,
}).then((summaries) => {
if (ignore) return;
setV22Summaries(summaries);
setV22SummaryState(summaries.length > 0 ? 'ready' : 'empty');
}).catch(() => {
if (ignore) return;
setV22Summaries([]);
setV22SummaryState('failed');
});
return () => {
ignore = true;
};
}, [canManage, v22UserId]);
useEffect(() => { if (shouldLoadFallback) fetchPlans(); }, [fetchPlans, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchRequirements(); }, [fetchRequirements, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchTasks(); }, [fetchTasks, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchTestCases(); }, [fetchTestCases, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchBugs(); }, [fetchBugs, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchActivities(); }, [fetchActivities, shouldLoadFallback]);
useEffect(() => { if (shouldLoadFallback) fetchWorklogs(); }, [fetchWorklogs, shouldLoadFallback]);
useEffect(() => {
if (loadRiskCache) fetchRiskData();
}, [fetchRiskData, loadRiskCache]);
@@ -93,7 +130,17 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
return map;
}, [visibleVersionScopeMap, visibleVersions]);
const risks = useMemo(() => visibleVersions.map((version) => {
const risks = useMemo(() => {
if (v22SummaryState === 'ready') {
return buildXiaobaoRisksFromV22Summaries({
summaries: filterV22XiaobaoSummariesForVisibleVersions(v22Summaries, visibleVersions),
versions: visibleVersions,
snapshots,
now: calculationNow,
});
}
return visibleVersions.map((version) => {
const scope = visibleVersionScopeMap[version.id];
const workItems = workItemsByVersion.get(version.id) ?? [];
const dailyEvidence = buildVersionDailyEvidence({
@@ -114,7 +161,8 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
now: calculationNow,
});
}).sort((a, b) => b.riskScore - a.riskScore), [
}).sort((a, b) => b.riskScore - a.riskScore);
}, [
activities,
visibleVersionScopeMap,
visibleVersions,
@@ -123,6 +171,8 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
snapshots,
today,
calculationNow,
v22Summaries,
v22SummaryState,
]);
return {

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildV22RequirementQuery } from './requirement-v22-query';
test('buildV22RequirementQuery creates a product-scoped query with server-side filters', () => {
const query = buildV22RequirementQuery({
selectedScope: { type: 'product', productId: 'product-1' },
projects: [],
statusFilter: 'adopted',
priorityFilter: 'P1',
typeFilter: 'feature',
versionFilter: 'version-1',
search: 'login',
dateSort: 'asc',
limit: 50,
cursor: 'req-1',
});
assert.deepEqual(query, {
productId: 'product-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
versionId: 'version-1',
q: 'login',
sort: 'created_at_asc',
limit: 50,
cursor: 'req-1',
});
});
test('buildV22RequirementQuery resolves project scope to product and project partition keys', () => {
const query = buildV22RequirementQuery({
selectedScope: { type: 'project', projectId: 'project-1' },
projects: [{ id: 'project-1', productId: 'product-1' }],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: ' ',
dateSort: 'desc',
limit: 20,
});
assert.deepEqual(query, {
productId: 'product-1',
projectId: 'project-1',
sort: 'created_at_desc',
limit: 20,
});
});
test('buildV22RequirementQuery skips scopes that would require full scans or derived status filters', () => {
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'all' },
projects: [],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'product', productId: 'product-1' },
projects: [],
statusFilter: 'dev_completed',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
assert.equal(buildV22RequirementQuery({
selectedScope: { type: 'project', projectId: 'missing-project' },
projects: [{ id: 'project-1', productId: 'product-1' }],
statusFilter: 'all',
priorityFilter: 'all',
typeFilter: 'all',
versionFilter: 'all',
search: '',
dateSort: 'desc',
limit: 20,
}), undefined);
});

View File

@@ -0,0 +1,73 @@
import type { RequirementScopeSelection } from './requirement-scope';
import type { RequirementDateSort } from './requirement-sort';
import type { V22RequirementsQuery } from './v22-api';
export interface RequirementV22ProjectRef {
id: string;
productId: string;
}
export interface BuildV22RequirementQueryInput {
selectedScope: RequirementScopeSelection;
projects: RequirementV22ProjectRef[];
statusFilter: string;
priorityFilter: string;
typeFilter: string;
versionFilter: string;
search: string;
dateSort: RequirementDateSort;
limit: number;
cursor?: string;
}
export function buildV22RequirementQuery(input: BuildV22RequirementQueryInput): V22RequirementsQuery | undefined {
if (input.statusFilter === 'dev_completed') return undefined;
const scoped = getPartitionScope(input.selectedScope, input.projects);
if (!scoped) return undefined;
return compactQuery({
...scoped,
status: input.statusFilter === 'all' ? undefined : input.statusFilter,
priority: input.priorityFilter === 'all' ? undefined : input.priorityFilter,
type: input.typeFilter === 'all' ? undefined : input.typeFilter,
versionId: input.versionFilter === 'all' ? undefined : input.versionFilter,
q: input.search.trim() || undefined,
sort: input.dateSort === 'asc' ? 'created_at_asc' : 'created_at_desc',
cursor: input.cursor,
limit: input.limit,
});
}
function getPartitionScope(
selectedScope: RequirementScopeSelection,
projects: RequirementV22ProjectRef[],
): Pick<V22RequirementsQuery, 'productId' | 'projectId'> | undefined {
if (selectedScope.type === 'product') {
const productId = selectedScope.productId.trim();
return productId ? { productId } : undefined;
}
if (selectedScope.type === 'project') {
const projectId = selectedScope.projectId.trim();
const project = projects.find((item) => item.id === projectId);
if (!project?.productId) return undefined;
return { productId: project.productId, projectId };
}
return undefined;
}
function compactQuery(query: V22RequirementsQuery): V22RequirementsQuery {
const result: V22RequirementsQuery = { productId: query.productId };
if (query.projectId) result.projectId = query.projectId;
if (query.versionId) result.versionId = query.versionId;
if (query.status) result.status = query.status;
if (query.priority) result.priority = query.priority;
if (query.type) result.type = query.type;
if (query.q) result.q = query.q;
if (query.sort) result.sort = query.sort;
if (query.cursor) result.cursor = query.cursor;
if (query.limit) result.limit = query.limit;
return result;
}

View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
import {
loadV22RequirementsPage,
loadV22VersionDetailData,
loadV22WorkspaceData,
loadV22XiaobaoWarnings,
} from './v22-api';
test('V2.2 version detail API maps relation rows to existing frontend scope types', async () => {
const originalFetch = globalThis.fetch;
const apiBase = resolveApiBase();
const calls: Array<{ url: string; method: string }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? 'GET' });
if (url === `${apiBase}/config/ai`) {
return jsonResponse({});
}
if (url === `${apiBase}/v2.2/versions/version-1/detail-data`) {
return jsonResponse({
version: {
id: 'version-1',
productId: 'product-1',
projectId: 'project-1',
name: 'Project A V1.0',
releaseDate: '2026-01-20T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
},
requirements: [{
id: 'req-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
code: 'REQ-001',
title: 'Login',
description: 'Password login',
status: 'adopted',
priority: 1,
type: 'feature',
sourceType: 'customer',
sourceTarget: 'ACME',
platform: 'web, ios',
creatorId: 'member-1',
createdAt: '2026-01-01T00:00:00.000Z',
}],
versionPlans: [{
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: 'Research login',
status: 'pending',
ownerId: 'member-1',
expectedStartAt: '2026-01-01',
expectedEndAt: '2026-01-05T18:00:00.000Z',
requirementCoverage: [{ requirementId: 'req-1', status: 'completed', updatedAt: '2026-01-02T00:00:00.000Z', updatedBy: 'member-1' }],
logs: [],
createdAt: '2026-01-01T00:00:00.000Z',
}],
devTasks: [{
id: 'dev-1',
versionId: 'version-1',
requirementId: 'req-1',
categoryId: 'cat-dev',
code: 'DEV-001',
title: 'Build login',
description: 'Implement login',
status: 'in_progress',
priority: 2,
assigneeId: 'member-2',
creatorId: 'member-1',
isBlocked: false,
expectedStartAt: '2026-01-02T09:00:00.000Z',
expectedEndAt: '2026-01-03T18:00:00.000Z',
startDate: '2026-01-02T09:00:00.000Z',
completedAt: null,
estimateHours: 8,
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
aiDraft: false,
createdAt: '2026-01-02T08:00:00.000Z',
updatedAt: '2026-01-02T09:00:00.000Z',
}],
testCases: [{
id: 'tc-1',
versionId: 'version-1',
requirementId: 'req-1',
categoryId: 'cat-test',
code: 'TC-001',
title: 'Verify login',
description: 'Check password login',
status: 'pending',
roundNo: 1,
priority: 3,
assigneeId: 'member-3',
creatorId: 'member-1',
references: [],
aiDraft: false,
createdAt: '2026-01-03T08:00:00.000Z',
updatedAt: '2026-01-03T08:00:00.000Z',
}],
bugs: [{
id: 'bug-1',
versionId: 'version-1',
testCaseId: 'tc-1',
code: 'BUG-001',
title: 'Login error',
description: 'Wrong prompt',
status: 'open',
severity: 'major',
priority: 0,
assigneeId: 'member-2',
reporterId: 'member-3',
createdAt: '2026-01-04T08:00:00.000Z',
updatedAt: '2026-01-04T08:00:00.000Z',
}],
});
}
throw new Error(`Unexpected fetch ${url}`);
}) as typeof fetch;
try {
__resetApiAvailabilityForTests();
const result = await loadV22VersionDetailData('version-1');
assert.equal(calls[1].url, `${apiBase}/v2.2/versions/version-1/detail-data`);
assert.equal(result.scope.requirements[0].typeId, 'feature');
assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']);
assert.equal(result.scope.requirements[0].priority, 'P1');
assert.equal(result.scope.requirements[0].creator, 'member-1');
assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001');
assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z');
assert.equal(result.scope.devTasks[0].actualEndAt, undefined);
assert.equal(result.scope.devTasks[0].createdBy, 'member-1');
assert.equal(result.scope.testCases[0].caseNo, 'TC-001');
assert.equal(result.scope.testCases[0].createdBy, 'member-1');
assert.equal(result.scope.bugs[0].bugNo, 'BUG-001');
assert.equal(result.scope.bugs[0].reportedBy, 'member-3');
assert.equal(result.scope.plans[0].owner, 'member-1');
assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z');
assert.equal(result.scope.plansByType.research[0].id, 'plan-1');
assert.equal(result.scope.requirementIdSet.has('req-1'), true);
} finally {
globalThis.fetch = originalFetch;
}
});
test('V2.2 list APIs build scoped query strings without full-table reads', async () => {
const originalFetch = globalThis.fetch;
const apiBase = resolveApiBase();
const urls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url === `${apiBase}/config/ai`) return jsonResponse({});
if (url.startsWith(`${apiBase}/v2.2/requirements?`)) return jsonResponse({ items: [], nextCursor: 'req-2' });
if (url.startsWith(`${apiBase}/v2.2/workspace?`)) return jsonResponse({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
if (url.startsWith(`${apiBase}/v2.2/xiaobao-warning?`)) return jsonResponse([]);
throw new Error(`Unexpected fetch ${url}`);
}) as typeof fetch;
try {
__resetApiAvailabilityForTests();
await loadV22RequirementsPage({
productId: 'product-1',
projectId: 'project-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
q: '登录',
sort: 'created_at_asc',
cursor: 'req-1',
limit: 50,
});
await loadV22WorkspaceData('member-1');
await loadV22XiaobaoWarnings({ userId: 'member-1' });
const requirementUrl = new URL(urls[1]);
assert.equal(requirementUrl.pathname.endsWith('/v2.2/requirements'), true);
assert.equal(requirementUrl.searchParams.get('productId'), 'product-1');
assert.equal(requirementUrl.searchParams.get('projectId'), 'project-1');
assert.equal(requirementUrl.searchParams.get('status'), 'adopted');
assert.equal(requirementUrl.searchParams.get('priority'), 'P1');
assert.equal(requirementUrl.searchParams.get('type'), 'feature');
assert.equal(requirementUrl.searchParams.get('q'), '登录');
assert.equal(requirementUrl.searchParams.get('sort'), 'created_at_asc');
assert.equal(requirementUrl.searchParams.get('cursor'), 'req-1');
assert.equal(requirementUrl.searchParams.get('limit'), '50');
assert.equal(new URL(urls[2]).searchParams.get('userId'), 'member-1');
assert.equal(new URL(urls[3]).searchParams.get('userId'), 'member-1');
} finally {
globalThis.fetch = originalFetch;
}
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}

481
apps/web/lib/v22-api.ts Normal file
View File

@@ -0,0 +1,481 @@
import { api } from './api';
import type { Bug, BugSeverity, BugStatus } from './bug';
import type { DevTask, DevTaskStatus, Reference } from './dev-task';
import type { Priority } from './derive';
import type { Requirement, RequirementStatus, SourceType } from './requirement';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionDataScope } from './version-data-scope';
import type {
PlanTask,
VersionPlan,
VersionPlanLog,
VersionPlanRequirementCoverage,
} from './version-plan';
type V22DateValue = string | Date | null | undefined;
interface V22VersionRow {
id: string;
productId: string;
projectId?: string | null;
name: string;
releaseDate?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22RequirementRow {
id: string;
productId: string;
projectId?: string | null;
versionId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
priority?: number | string | null;
type?: string | null;
sourceType?: string | null;
sourceTarget?: string | null;
platform?: string | null;
creatorId?: string | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22VersionPlanRow {
id: string;
versionId: string;
type: string;
title: string;
status?: string | null;
ownerId?: string | null;
expectedStartAt?: V22DateValue;
expectedEndAt?: V22DateValue;
actualStartAt?: V22DateValue;
completedAt?: V22DateValue;
resultUrl?: string | null;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22DevTaskRow {
id: string;
versionId: string;
requirementId?: string | null;
categoryId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
priority?: number | string | null;
assigneeId?: string | null;
creatorId?: string | null;
isBlocked?: boolean | null;
blockReason?: string | null;
expectedStartAt?: V22DateValue;
expectedEndAt?: V22DateValue;
startDate?: V22DateValue;
completedAt?: V22DateValue;
estimateHours?: number | null;
aiEstimateHours?: number | null;
references?: unknown;
aiDraft?: boolean | null;
aiDraftAt?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22TestCaseRow {
id: string;
versionId: string;
requirementId?: string | null;
categoryId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
roundNo?: number | null;
priority?: number | string | null;
assigneeId?: string | null;
creatorId?: string | null;
plannedTestAt?: V22DateValue;
plannedEndAt?: V22DateValue;
startedAt?: V22DateValue;
completedAt?: V22DateValue;
estimateHours?: number | null;
aiEstimateHours?: number | null;
references?: unknown;
aiDraft?: boolean | null;
aiDraftAt?: V22DateValue;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22BugRow {
id: string;
versionId: string;
testCaseId?: string | null;
code: string;
title: string;
description?: string | null;
status?: string | null;
severity?: string | null;
priority?: number | string | null;
assigneeId?: string | null;
reporterId?: string | null;
plannedFixAt?: V22DateValue;
resolvedAt?: V22DateValue;
closedAt?: V22DateValue;
resolution?: string | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
interface V22VersionDetailResponse {
version: V22VersionRow;
requirements: V22RequirementRow[];
versionPlans: V22VersionPlanRow[];
devTasks: V22DevTaskRow[];
testCases: V22TestCaseRow[];
bugs: V22BugRow[];
}
export interface V22VersionDetailData {
version: V22VersionRow;
scope: VersionDataScope;
}
export interface V22RequirementsQuery {
productId: string;
projectId?: string;
versionId?: string;
status?: string;
priority?: string;
type?: string;
q?: string;
sort?: string;
cursor?: string;
limit?: number;
}
export interface V22RequirementsPage {
items: Requirement[];
nextCursor?: string;
}
export interface V22WorkspaceData {
versionPlans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
}
export interface V22XiaobaoWarningQuery {
userId?: string;
manager?: boolean;
}
export interface V22XiaobaoWarningSummary {
versionId: string;
riskLevel: string;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
riskSignature: string;
summary: unknown;
dirty?: boolean;
recomputedAt?: string;
updatedAt?: string;
}
export async function loadV22VersionDetailData(versionId: string): Promise<V22VersionDetailData> {
const response = await api.get<V22VersionDetailResponse>(
`/v2.2/versions/${encodeURIComponent(versionId)}/detail-data`,
);
return {
version: response.version,
scope: buildScope(response),
};
}
export async function loadV22RequirementsPage(query: V22RequirementsQuery): Promise<V22RequirementsPage> {
const response = await api.get<{ items: V22RequirementRow[]; nextCursor?: string }>(
`/v2.2/requirements?${queryString(query)}`,
);
return {
items: response.items.map(mapRequirement),
nextCursor: response.nextCursor,
};
}
export async function loadV22WorkspaceData(userId: string): Promise<V22WorkspaceData> {
const response = await api.get<{
versionPlans: V22VersionPlanRow[];
devTasks: V22DevTaskRow[];
testCases: V22TestCaseRow[];
bugs: V22BugRow[];
}>(`/v2.2/workspace?${queryString({ userId })}`);
return {
versionPlans: response.versionPlans.map(mapVersionPlan),
devTasks: response.devTasks.map(mapDevTask),
testCases: response.testCases.map(mapTestCase),
bugs: response.bugs.map(mapBug),
};
}
export async function loadV22XiaobaoWarnings(
query: V22XiaobaoWarningQuery,
): Promise<V22XiaobaoWarningSummary[]> {
const response = await api.get<Array<Omit<V22XiaobaoWarningSummary, 'forecastReleaseDate' | 'recomputedAt' | 'updatedAt'> & {
forecastReleaseDate?: V22DateValue;
recomputedAt?: V22DateValue;
updatedAt?: V22DateValue;
}>>(`/v2.2/xiaobao-warning?${queryString({
userId: query.userId,
manager: query.manager ? 'true' : undefined,
})}`);
return response.map((row) => ({
...row,
forecastReleaseDate: optionalIso(row.forecastReleaseDate),
recomputedAt: optionalIso(row.recomputedAt),
updatedAt: optionalIso(row.updatedAt),
}));
}
function buildScope(response: V22VersionDetailResponse): VersionDataScope {
const requirements = response.requirements.map(mapRequirement);
const requirementIds = requirements.map((requirement) => requirement.id);
const requirementIdSet = new Set(requirementIds);
const plans = response.versionPlans.map(mapVersionPlan);
const plansByType: VersionDataScope['plansByType'] = { research: [], product: [], ui: [] };
for (const plan of plans) {
plansByType[plan.type].push(plan);
}
return {
requirements,
requirementIds,
requirementIdSet,
plans,
plansByType,
devTasks: response.devTasks.map(mapDevTask),
testCases: response.testCases.map(mapTestCase),
bugs: response.bugs.map(mapBug),
overtimeRecords: [],
};
}
function mapRequirement(row: V22RequirementRow): Requirement {
return {
id: row.id,
code: row.code,
title: row.title,
description: row.description ?? '',
productId: row.productId,
projectId: row.projectId ?? '',
versionId: row.versionId ?? undefined,
sourceType: toSourceType(row.sourceType),
sourceTarget: row.sourceTarget ?? '',
platforms: splitCsv(row.platform),
typeId: row.type ?? '',
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
creator: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
};
}
function mapVersionPlan(row: V22VersionPlanRow): VersionPlan {
const coverage = asArray<VersionPlanRequirementCoverage>(row.requirementCoverage);
return {
id: row.id,
versionId: row.versionId,
type: toPlanType(row.type),
title: row.title,
owner: row.ownerId ?? '',
startTime: requiredIso(row.expectedStartAt),
endTime: requiredIso(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
completedRequirementIds: coverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)
.filter(Boolean),
linkedRequirementIds: coverage.map((item) => item?.requirementId).filter(Boolean),
requirementCoverage: coverage,
logs: asArray<VersionPlanLog>(row.logs),
resultUrl: row.resultUrl ?? undefined,
actualStartAt: optionalIso(row.actualStartAt),
createdAt: requiredIso(row.createdAt),
completedAt: optionalIso(row.completedAt),
addedBy: row.ownerId ?? '',
};
}
function mapDevTask(row: V22DevTaskRow): DevTask {
return {
id: row.id,
taskNo: row.code,
versionId: row.versionId,
requirementId: row.requirementId ?? '',
title: row.title,
description: row.description ?? '',
categoryId: row.categoryId ?? '',
assigneeId: row.assigneeId ?? '',
priority: toPriority(row.priority),
expectedStartAt: requiredIso(row.expectedStartAt),
expectedEndAt: requiredIso(row.expectedEndAt),
estimateHours: row.estimateHours ?? undefined,
aiEstimateHours: row.aiEstimateHours ?? undefined,
actualStartAt: optionalIso(row.startDate),
actualEndAt: optionalIso(row.completedAt),
status: toDevTaskStatus(row.status),
isBlocked: row.isBlocked ?? false,
blockReason: row.blockReason ?? undefined,
references: asArray<Reference>(row.references),
aiDraft: row.aiDraft ?? false,
aiDraftAt: optionalIso(row.aiDraftAt),
createdBy: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function mapTestCase(row: V22TestCaseRow): TestCase {
return {
id: row.id,
caseNo: row.code,
versionId: row.versionId,
requirementId: row.requirementId ?? undefined,
roundNo: row.roundNo ?? 1,
title: row.title,
description: row.description ?? '',
categoryId: row.categoryId ?? '',
priority: toPriority(row.priority),
assigneeId: row.assigneeId ?? undefined,
status: toTestCaseStatus(row.status),
estimateHours: row.estimateHours ?? undefined,
aiEstimateHours: row.aiEstimateHours ?? undefined,
plannedTestAt: optionalIso(row.plannedTestAt),
plannedEndAt: optionalIso(row.plannedEndAt),
startedAt: optionalIso(row.startedAt),
completedAt: optionalIso(row.completedAt),
references: asArray<Reference>(row.references),
aiDraft: row.aiDraft ?? false,
aiDraftAt: optionalIso(row.aiDraftAt),
createdBy: row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function mapBug(row: V22BugRow): Bug {
return {
id: row.id,
bugNo: row.code,
versionId: row.versionId,
testCaseId: row.testCaseId ?? '',
title: row.title,
description: row.description ?? '',
severity: toBugSeverity(row.severity),
priority: toPriority(row.priority),
reportedBy: row.reporterId ?? '',
assigneeId: row.assigneeId ?? '',
status: toBugStatus(row.status),
resolvedAt: optionalIso(row.resolvedAt),
closedAt: optionalIso(row.closedAt),
resolution: row.resolution ?? undefined,
plannedFixAt: optionalIso(row.plannedFixAt),
createdAt: requiredIso(row.createdAt),
updatedAt: requiredIso(row.updatedAt),
};
}
function queryString<T extends object>(values: T): string {
const params = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value === undefined || value === '') return;
params.set(key, String(value));
});
return params.toString();
}
function requiredIso(value: V22DateValue): string {
return optionalIso(value) ?? '';
}
function optionalIso(value: V22DateValue): string | undefined {
if (!value) return undefined;
if (value instanceof Date) return value.toISOString();
const trimmed = value.trim();
if (!trimmed) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return `${trimmed}T00:00:00.000Z`;
const parsed = new Date(trimmed);
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
return trimmed;
}
function splitCsv(value?: string | null): string[] {
if (!value) return [];
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
function toPriority(value: number | string | null | undefined): Priority {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (/^P[0-4]$/.test(normalized)) return normalized as Priority;
const parsed = Number(normalized);
if (Number.isFinite(parsed)) return toPriority(parsed);
}
const rank = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 2;
return `P${Math.max(0, Math.min(4, rank))}` as Priority;
}
function toSourceType(value?: string | null): SourceType {
const allowed: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
return allowed.includes(value as SourceType) ? value as SourceType : 'internal';
}
function toRequirementStatus(value?: string | null): RequirementStatus {
const allowed: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed'];
return allowed.includes(value as RequirementStatus) ? value as RequirementStatus : 'pending_review';
}
function toPlanType(value: string): VersionPlan['type'] {
return value === 'research' || value === 'ui' ? value : 'product';
}
function toPlanStatus(value?: string | null): VersionPlan['status'] {
if (value === 'in_progress' || value === 'completed') return value;
return 'pending';
}
function toDevTaskStatus(value?: string | null): DevTaskStatus {
if (value === 'in_progress' || value === 'testing' || value === 'submitted') return value;
return 'todo';
}
function toTestCaseStatus(value?: string | null): TestCaseStatus {
if (value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked') return value;
return 'pending';
}
function toBugStatus(value?: string | null): BugStatus {
if (value === 'fixing' || value === 'fixed' || value === 'verifying' || value === 'closed' || value === 'rejected') {
return value;
}
return 'open';
}
function toBugSeverity(value?: string | null): BugSeverity {
if (value === 'critical' || value === 'major' || value === 'trivial') return value;
return 'minor';
}

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope';
import { buildVersionDataScope, buildVersionDataScopeMap, selectVersionDataScope } from './version-data-scope';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { OvertimeRecord } from './overtime';
@@ -169,3 +169,53 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => {
assert.deepEqual(scopeMap['version-empty'].requirements, []);
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
});
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData overtime records', () => {
const appDataScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [plan('plan-app', 'version-1', 'research')],
devTasks: [devTask('dev-app', { versionId: 'version-1' })],
testCases: [testCase('tc-app', 'version-1')],
bugs: [bug('bug-app', 'version-1')],
overtimeRecords: [overtime('ot-app', 'version-1')],
});
const v22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-v22', 'version-1')],
plans: [plan('plan-v22', 'version-1', 'product')],
devTasks: [devTask('dev-v22', { versionId: 'version-1' })],
testCases: [],
bugs: [],
overtimeRecords: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope });
assert.equal(selected?.requirements[0].id, 'req-v22');
assert.equal(selected?.plans[0].id, 'plan-v22');
assert.deepEqual(selected?.overtimeRecords.map((item) => item.id), ['ot-app']);
});
test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', () => {
const appDataScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [],
devTasks: [],
testCases: [],
bugs: [],
});
const emptyV22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [],
plans: [],
devTasks: [],
testCases: [],
bugs: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope });
assert.equal(selected?.requirements[0].id, 'req-app');
});

View File

@@ -77,6 +77,31 @@ export function buildVersionDataScope(input: VersionDataScopeInput): VersionData
};
}
export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefined): scope is VersionDataScope {
if (!scope) return false;
return (
scope.requirements.length > 0 ||
scope.plans.length > 0 ||
scope.devTasks.length > 0 ||
scope.testCases.length > 0 ||
scope.bugs.length > 0
);
}
export function selectVersionDataScope(input: {
appDataScope: VersionDataScope | null;
v22Scope?: VersionDataScope | null;
}): VersionDataScope | null {
const { appDataScope, v22Scope } = input;
if (hasVersionDataScopeRows(v22Scope)) {
return {
...v22Scope,
overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords,
};
}
return appDataScope;
}
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
const scopes: Record<string, VersionDataScope> = {};
const targetVersionIds = new Set(input.versionIds);

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectWorkspaceCollections } from './workspace-v22-source';
const appData = {
plans: [{ id: 'app-plan' }] as any[],
devTasks: [{ id: 'app-dev' }] as any[],
testCases: [{ id: 'app-test' }] as any[],
bugs: [{ id: 'app-bug' }] as any[],
};
test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => {
const selected = selectWorkspaceCollections({
v22Loaded: true,
v22Failed: false,
v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] },
appData,
});
assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] });
});
test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => {
const selected = selectWorkspaceCollections({
v22Loaded: false,
v22Failed: true,
v22Data: null,
appData,
});
assert.deepEqual(selected, {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
});
});

View File

@@ -0,0 +1,47 @@
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { V22WorkspaceData } from './v22-api';
import type { VersionPlan } from './version-plan';
export interface WorkspaceCollections {
versionPlans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
}
export interface SelectWorkspaceCollectionsInput {
v22Loaded: boolean;
v22Failed: boolean;
v22Data: V22WorkspaceData | null;
appData: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
};
}
export function selectWorkspaceCollections({
v22Loaded,
v22Failed,
v22Data,
appData,
}: SelectWorkspaceCollectionsInput): WorkspaceCollections {
if (v22Loaded && !v22Failed && v22Data) {
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
};
}
return {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
};
}

View File

@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { VersionWithContext } from './derive';
import type { V22XiaobaoWarningSummary } from './v22-api';
import {
buildXiaobaoRisksFromV22Summaries,
filterV22XiaobaoSummariesForVisibleVersions,
shouldLoadXiaobaoAppDataFallback,
} from './xiaobao-v22-summary';
test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => {
const risks = buildXiaobaoRisksFromV22Summaries({
summaries: [{
versionId: 'version-1',
riskLevel: 'likely_delayed',
riskScore: 86,
confidence: 72,
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
riskSignature: 'signature-1',
recomputedAt: '2026-07-03T09:00:00.000Z',
updatedAt: '2026-07-03T09:05:00.000Z',
summary: {
delayDays: 2,
remainingWorkHours: 30,
reasons: [{
key: 'forecast_delay',
title: 'Forecast delay',
detail: 'Forecast release date moved after the expected release date.',
severity: 'danger',
count: 1,
}],
silentRisks: [{
key: 'no_activity',
title: 'No activity',
detail: 'No work activity for four days.',
itemId: 'dev-1',
itemType: 'dev_task',
}],
signals: {
unfinishedCount: 5,
openBugCount: 2,
criticalBugCount: 1,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 1,
daysToExpectedRelease: 1,
},
dailyEvidence: {
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core task.', occurredAt: '2026-07-03T08:00:00.000Z' }],
todayProgress: [],
todayCreations: [],
todayRisks: [],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 3,
totalActivityCount: 1,
todayActualHours: 2,
lastActivityAt: '2026-07-03T08:00:00.000Z',
},
trend: {
direction: 'up',
delta: 16,
summary: 'Risk rose by 16 points.',
pattern: 'score_delta',
},
currentSnapshot: {
date: '2026-07-03',
openBugCount: 2,
criticalBugCount: 1,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 1,
createdAt: '2026-07-03T09:00:00.000Z',
},
},
}],
versions: [version()],
snapshots: [],
now: new Date('2026-07-03T10:00:00.000Z'),
});
assert.equal(risks.length, 1);
assert.equal(risks[0].versionName, 'Payments V1.0');
assert.equal(risks[0].productName, 'Payments');
assert.equal(risks[0].projectName, 'Wallet');
assert.equal(risks[0].expectedReleaseDate, '2026-07-07');
assert.equal(risks[0].riskLevel, 'likely_delayed');
assert.equal(risks[0].riskScore, 86);
assert.equal(risks[0].confidence, 72);
assert.equal(risks[0].confidenceLevel, 'medium');
assert.equal(risks[0].forecastReleaseDate, '2026-07-09T00:00:00.000Z');
assert.equal(risks[0].delayDays, 2);
assert.equal(risks[0].remainingWorkHours, 30);
assert.equal(risks[0].reasons[0].key, 'forecast_delay');
assert.equal(risks[0].silentRisks[0].itemType, 'dev_task');
assert.equal(risks[0].signals.openBugCount, 2);
assert.equal(risks[0].dailyEvidence?.todayDeliveries[0].summary, 'Submitted core task.');
assert.equal(risks[0].currentSnapshot.date, '2026-07-03');
assert.equal(risks[0].currentSnapshot.riskScore, 86);
assert.equal(risks[0].currentSnapshot.riskLevel, 'likely_delayed');
assert.equal(risks[0].trend.summary, 'Risk rose by 16 points.');
});
test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse summaries', () => {
const risks = buildXiaobaoRisksFromV22Summaries({
summaries: [{
versionId: 'version-1',
riskLevel: 'at_risk',
riskScore: 66,
confidence: 48,
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
riskSignature: 'signature-1',
updatedAt: '2026-07-03T09:05:00.000Z',
summary: { reasons: 'not-an-array' },
}],
versions: [version({ expectedReleaseDate: '2026-07-07' })],
snapshots: [{
versionId: 'version-1',
date: '2026-07-01',
riskScore: 40,
riskLevel: 'attention',
openBugCount: 1,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-07-01T09:00:00.000Z',
}],
now: new Date('2026-07-03T10:00:00.000Z'),
});
assert.equal(risks[0].delayDays, 2);
assert.equal(risks[0].remainingWorkHours, 0);
assert.equal(risks[0].confidenceLevel, 'low');
assert.equal(risks[0].reasons.length, 1);
assert.equal(risks[0].reasons[0].key, 'precomputed_risk');
assert.equal(risks[0].signals.unfinishedCount, 0);
assert.equal(risks[0].currentSnapshot.openBugCount, 0);
assert.equal(risks[0].currentSnapshot.createdAt, '2026-07-03T09:05:00.000Z');
assert.equal(risks[0].trend.direction, 'up');
});
test('shouldLoadXiaobaoAppDataFallback waits for V2.2 summary before loading heavy AppData stores', () => {
assert.equal(shouldLoadXiaobaoAppDataFallback('idle'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('loading'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('ready'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('empty'), true);
assert.equal(shouldLoadXiaobaoAppDataFallback('failed'), true);
});
test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => {
const summaries = [
summary({ versionId: 'visible-1', riskScore: 80 }),
summary({ versionId: 'hidden-1', riskScore: 95 }),
summary({ versionId: 'visible-2', riskScore: 70 }),
];
const result = filterV22XiaobaoSummariesForVisibleVersions(summaries, [
version({ id: 'visible-1' }),
version({ id: 'visible-2' }),
]);
assert.deepEqual(result.map((item) => item.versionId), ['visible-1', 'visible-2']);
});
function summary(patch: Partial<V22XiaobaoWarningSummary> = {}): V22XiaobaoWarningSummary {
return {
versionId: 'version-1',
riskLevel: 'at_risk',
riskScore: 60,
confidence: 80,
riskSignature: 'signature',
summary: {},
updatedAt: '2026-07-03T09:00:00.000Z',
...patch,
};
}
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
return {
id: 'version-1',
name: 'Payments V1.0',
status: 'developing',
releaseDate: null,
createdAt: '2026-07-01T00:00:00.000Z',
productId: 'product-1',
productName: 'Payments',
projectId: 'project-1',
projectName: 'Wallet',
expectedReleaseDate: '2026-07-07',
members: [{ name: 'Alice', role: 'frontend' }],
...patch,
} as VersionWithContext;
}

View File

@@ -0,0 +1,345 @@
import type { VersionWithContext } from './derive';
import type { V22XiaobaoWarningSummary } from './v22-api';
import type {
RiskReason,
SilentRisk,
XiaobaoConfidenceLevel,
XiaobaoRiskLevel,
XiaobaoRiskSignals,
XiaobaoVersionRisk,
} from './xiaobao-risk';
import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence';
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
interface BuildXiaobaoRisksFromV22SummariesInput {
summaries: V22XiaobaoWarningSummary[];
versions: VersionWithContext[];
snapshots?: XiaobaoRiskSnapshot[];
now?: Date;
}
export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed';
const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']);
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean {
return state === 'empty' || state === 'failed';
}
export function filterV22XiaobaoSummariesForVisibleVersions(
summaries: V22XiaobaoWarningSummary[],
versions: VersionWithContext[],
): V22XiaobaoWarningSummary[] {
const visibleVersionIds = new Set(versions.map((version) => version.id));
if (visibleVersionIds.size === 0) return [];
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
}
export function buildXiaobaoRisksFromV22Summaries(
input: BuildXiaobaoRisksFromV22SummariesInput,
): XiaobaoVersionRisk[] {
const now = input.now ?? new Date();
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
return input.summaries
.map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now))
.sort((a, b) => b.riskScore - a.riskScore);
}
function mapSummaryToRisk(
row: V22XiaobaoWarningSummary,
version: VersionWithContext | undefined,
snapshots: XiaobaoRiskSnapshot[],
now: Date,
): XiaobaoVersionRisk {
const payload = getRiskPayload(row.summary);
const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention';
const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore);
const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence);
const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate;
const expectedReleaseDate =
readString(payload.expectedReleaseDate)
?? version?.expectedReleaseDate
?? version?.releaseDate
?? null;
const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate);
const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0;
const dailyEvidence = readDailyEvidence(payload.dailyEvidence);
const silentRisks = readSilentRisks(payload.silentRisks);
const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now);
const reasons = readReasons(payload.reasons);
const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString();
const currentSnapshot = buildCurrentSnapshot({
payload,
row,
riskLevel,
riskScore,
confidence,
forecastReleaseDate,
signals,
sourceTime,
now,
});
const trend = readTrend(payload.trend)
?? summarizeRiskTrendWithCurrent(
snapshots.filter((snapshot) => snapshot.versionId === row.versionId),
currentSnapshot,
);
return {
versionId: row.versionId,
versionName: readString(payload.versionName) ?? version?.name ?? row.versionId,
productId: readString(payload.productId) ?? version?.productId,
productName: readString(payload.productName) ?? version?.productName,
projectId: readString(payload.projectId) ?? version?.projectId,
projectName: readString(payload.projectName) ?? version?.projectName,
riskScore,
riskLevel,
expectedReleaseDate,
confidence,
confidenceLevel: getConfidenceLevel(confidence),
forecastReleaseDate,
delayDays,
remainingWorkHours,
reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore),
silentRisks,
dailyEvidence,
signals,
currentSnapshot,
trend,
};
}
function getRiskPayload(summary: unknown): Record<string, unknown> {
const root = readRecord(summary);
const nested = readRecord(root.risk);
return Object.keys(nested).length > 0 ? nested : root;
}
function buildCurrentSnapshot(input: {
payload: Record<string, unknown>;
row: V22XiaobaoWarningSummary;
riskLevel: XiaobaoRiskLevel;
riskScore: number;
confidence: number;
forecastReleaseDate?: string;
signals: XiaobaoRiskSignals;
sourceTime: string;
now: Date;
}): XiaobaoRiskSnapshot {
const snapshot = readRecord(input.payload.currentSnapshot);
const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString();
return {
versionId: input.row.versionId,
date: readString(snapshot.date) ?? createdAt.slice(0, 10),
riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore),
riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel,
forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate,
openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount,
criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount,
failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount,
blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount,
silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount,
confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence),
createdAt,
};
}
function readSignals(
value: unknown,
dailyEvidence: VersionDailyEvidence | undefined,
silentRisks: SilentRisk[],
expectedReleaseDate: string | null,
now: Date,
): XiaobaoRiskSignals {
const row = readRecord(value);
return {
unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0,
openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0,
criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0,
failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0,
blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0,
silentRiskCount:
nonNegativeInteger(readNumber(row.silentRiskCount))
?? dailyEvidence?.silentRisks?.length
?? silentRisks.length,
daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now),
};
}
function readReasons(value: unknown): RiskReason[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
severity: toReasonSeverity(readString(row.severity)),
count: nonNegativeInteger(readNumber(row.count)),
};
}).filter(isDefined);
}
function readSilentRisks(value: unknown): SilentRisk[] {
if (!Array.isArray(value)) return [];
return value.map((item) => {
const row = readRecord(item);
const key = readString(row.key);
const title = readString(row.title);
const detail = readString(row.detail);
if (!key || !title || !detail) return undefined;
return {
key,
title,
detail,
itemId: readString(row.itemId),
itemType: toSilentRiskItemType(readString(row.itemType)),
};
}).filter(isDefined);
}
function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined {
const row = readRecord(value);
if (Object.keys(row).length === 0) return undefined;
return {
todayDeliveries: readEvidenceItems(row.todayDeliveries),
todayProgress: readEvidenceItems(row.todayProgress),
todayCreations: readEvidenceItems(row.todayCreations),
todayRisks: readEvidenceItems(row.todayRisks),
progressNotes: readEvidenceItems(row.progressNotes),
needsProgressItems: readEvidenceItems(row.needsProgressItems),
recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0,
totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0,
todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0,
lastActivityAt: readString(row.lastActivityAt),
silentRisks: readSilentRisks(row.silentRisks),
};
}
function readEvidenceItems(value: unknown): EvidenceItem[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => {
const row = readRecord(item);
const summary = readString(row.summary);
if (!summary) return undefined;
return {
id: readString(row.id) ?? `v22-evidence-${index}`,
title: readString(row.title) ?? summary,
summary,
occurredAt: readString(row.occurredAt) ?? '',
actorId: readString(row.actorId),
};
}).filter(isDefined);
}
function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined {
const row = readRecord(value);
const direction = readString(row.direction);
const summary = readString(row.summary);
if (!direction || !summary) return undefined;
if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined;
const pattern = readString(row.pattern);
return {
direction,
delta: readNumber(row.delta) ?? 0,
summary,
pattern: pattern === 'continuous_rising'
|| pattern === 'continuous_falling'
|| pattern === 'score_delta'
|| pattern === 'stable'
|| pattern === 'unknown'
? pattern
: undefined,
};
}
function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] {
if (riskLevel === 'on_track') return [];
return [{
key: 'precomputed_risk',
title: 'Precomputed risk',
detail: `V2.2 precomputed summary reports risk score ${riskScore}.`,
severity: riskScore >= 75 ? 'danger' : 'warning',
}];
}
function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined {
return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined;
}
function toReasonSeverity(value: string | undefined): RiskReason['severity'] {
return value && REASON_SEVERITIES.has(value as RiskReason['severity'])
? value as RiskReason['severity']
: 'warning';
}
function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined {
return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable<SilentRisk['itemType']>)
? value as NonNullable<SilentRisk['itemType']>
: undefined;
}
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
if (confidence >= 75) return 'high';
if (confidence >= 50) return 'medium';
return 'low';
}
function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number {
const expected = parseDate(expectedReleaseDate);
const forecast = parseDate(forecastReleaseDate);
if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0;
return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10;
}
function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined {
const expected = parseDate(expectedReleaseDate);
if (!expected) return undefined;
return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10;
}
function parseDate(value: string | null | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function nonNegativeNumber(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? value : undefined;
}
function nonNegativeInteger(value: number | undefined): number | undefined {
return value !== undefined && value >= 0 ? Math.round(value) : undefined;
}
function clampScore(value: number): number {
return Math.max(0, Math.min(100, Math.round(value)));
}
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}

View File

@@ -243,3 +243,12 @@ Partitioned tables must include the partition key in every primary key and busin
Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis.
The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled.
The first V2.2 read layer is query-first and AppData-compatible. `V22QueryModule` exposes scoped read APIs for version detail, requirement pool, workspace, and Xiaobao warning summaries:
- Version detail reads only the current version's requirements, plans, dev tasks, test cases, and bugs.
- Requirement pool queries require `productId`, so list/search/filter operations stay on the `requirements.product_id` partition key.
- Workspace reads only the current user's unfinished plans, dev tasks, test cases, and bugs.
- Xiaobao warning reads `xiaobao_risk_summaries` first, then maps the precomputed summary into the existing warning UI shape.
During the V2.2 compatibility window, writes still go through the existing AppData stores. The frontend consumes V2.2 relation-table results for render-heavy pages and falls back to AppData only when the V2.2 read is unavailable or empty.

View File

@@ -1,8 +1,8 @@
# 开发路线图
## 当前阶段V2.1服务端持久化第一阶段
## 当前阶段V2.2分区关系表与高频读取热路径
业务流程仍保持 V1 的前端 store 形状,但业务数据主存储已切到 NestJS + PostgreSQL `app_data` 文档表。浏览器只保留登录态,不再保存产品、项目、版本、需求、版本详情、成员、任务类型等业务数据
V2.2 已完成第一批高频读取热路径分区关系表基础、AppData 迁移预演、V2.2 scoped read API以及版本详情、需求池、与我相关、小宝预警的前端快读接入。业务写入仍保留现有 AppData store 兼容窗口,后续再逐步打开关系表写入和领域 CRUD
### 已完成(按时间倒序)
@@ -12,6 +12,11 @@
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.
- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key.
- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled.
- V2.2 scoped read API added for version detail, requirement pool, workspace, and Xiaobao warning summaries.
- Version detail, requirement pool, workspace, sidebar badges, version list Xiaobao indicators, and Xiaobao warning page now prefer V2.2 fast-read data and fall back to AppData only for compatibility.
- Requirement pool V2.2 queries require `productId`, avoiding accidental full-table scans against annual hundreds-of-thousands-row data.
- Xiaobao warning now consumes precomputed `xiaobao_risk_summaries` before loading heavy AppData task/test/bug/activity documents.
- V2.2 completion boundary: read hot paths are complete; writes remain on AppData until the relation-table write APIs are enabled.
**2026-07-02**
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新

View File

@@ -0,0 +1,134 @@
# V2.2 Performance Hot Path Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish V2.2 by moving high-volume read paths from full AppData document scans to partition-key-scoped relation-table queries, with safe AppData fallback for existing writes.
**Architecture:** Backend exposes read-optimized V2.2 query endpoints for version detail, requirement pool, workspace, and Xiaobao warning summary data. Frontend adds a single mapping layer that converts Prisma-shaped rows into the existing UI types, then pages consume that layer first and fall back to the existing Zustand/AppData stores when the relation tables are unavailable or empty.
**Tech Stack:** Next.js 14 App Router, Zustand, NestJS, Prisma, PostgreSQL partitioned tables, node:test, Jest.
---
### Task 1: Frontend V2.2 API Mapping
**Files:**
- Create: `apps/web/lib/v22-api.ts`
- Create: `apps/web/lib/v22-api.test.ts`
- [ ] **Step 1: Write the failing test**
Add `apps/web/lib/v22-api.test.ts` with tests that mock `fetch`, call `loadV22VersionDetailData('version-1')`, and assert:
```ts
assert.equal(calls[1].url.endsWith('/v2.2/versions/version-1/detail-data'), true);
assert.equal(result.scope.requirements[0].typeId, 'feature');
assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']);
assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001');
assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z');
assert.equal(result.scope.testCases[0].caseNo, 'TC-001');
assert.equal(result.scope.bugs[0].bugNo, 'BUG-001');
assert.equal(result.scope.plans[0].owner, 'member-1');
assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z');
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter web test`
Expected: FAIL because `apps/web/lib/v22-api.ts` does not exist.
- [ ] **Step 3: Implement minimal mapping layer**
Create `apps/web/lib/v22-api.ts` exporting:
```ts
loadV22VersionDetailData(versionId: string)
loadV22RequirementsPage(query)
loadV22WorkspaceData(userId: string)
loadV22XiaobaoWarnings(query)
```
The mapper must convert `priority: number` to `P0`-style priorities, `platform` CSV to `platforms[]`, `code` to `taskNo/caseNo/bugNo`, `ownerId` to `owner`, and date-like values to ISO strings.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm --filter web test`
Expected: PASS.
### Task 2: Version Detail Hot Path
**Files:**
- Modify: `apps/web/app/versions/[id]/page.tsx`
- [ ] **Step 1: Write or extend testable helper first**
Use Task 1 mapping tests as the behavior guard for the data shape consumed by the page.
- [ ] **Step 2: Replace full child-data fetches for read-heavy render**
In `apps/web/app/versions/[id]/page.tsx`, load `loadV22VersionDetailData(versionId)` in an effect. Use its returned scope for overview, requirement, plan, dev-task, test-case, and bug tab props when available. Keep the existing stores loaded for overview, members, categories, overtime, drawers, and mutations.
- [ ] **Step 3: Keep fallback behavior**
If the V2.2 request fails or returns an empty relation scope, keep using `buildVersionDataScope(...)` from AppData stores.
- [ ] **Step 4: Verify**
Run: `pnpm --filter web type-check` and `pnpm --filter web test`.
Expected: both exit 0.
### Task 3: Backend And Migration Verification
**Files:**
- Already modified: `apps/server/src/modules/migration/*`
- Already created: `apps/server/src/modules/v22-query/*`
- Modify if needed: `apps/server/src/app.module.ts`
- [ ] **Step 1: Verify Prisma schema**
Run: `pnpm --filter server exec prisma validate --schema prisma/schema.prisma`
Expected: schema validates.
- [ ] **Step 2: Verify backend types and tests**
Run: `pnpm --filter server type-check`
Run from `apps/server`: `$env:NODE_OPTIONS='--max-old-space-size=4096'; .\node_modules\.bin\jest.CMD --runInBand`
Expected: both exit 0.
### Task 4: Documentation And Commit
**Files:**
- Modify: `docs/architecture.md`
- Modify: `docs/roadmap.md`
- [ ] **Step 1: Document V2.2 completion boundary**
Update docs to say V2.2 relation tables, import execution, read query API, and version-detail frontend hot path are complete. State that writes still go through AppData during the compatibility window.
- [ ] **Step 2: Final verification**
Run:
```powershell
pnpm --filter web type-check
pnpm --filter web test
pnpm --filter server type-check
pnpm --filter server exec prisma validate --schema prisma/schema.prisma
```
Run full backend Jest from `apps/server`.
- [ ] **Step 3: Commit locally**
```powershell
git add apps/server apps/web docs
git commit -m "feat(v2.2): 完成高频读取热路径"
```
Do not push unless the user explicitly asks.