feat(v2.2): 完成高频读取热路径
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
52
apps/server/src/modules/v22-query/v22-query.controller.ts
Normal file
52
apps/server/src/modules/v22-query/v22-query.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
9
apps/server/src/modules/v22-query/v22-query.module.ts
Normal file
9
apps/server/src/modules/v22-query/v22-query.module.ts
Normal 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 {}
|
||||
157
apps/server/src/modules/v22-query/v22-query.service.spec.ts
Normal file
157
apps/server/src/modules/v22-query/v22-query.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
185
apps/server/src/modules/v22-query/v22-query.service.ts
Normal file
185
apps/server/src/modules/v22-query/v22-query.service.ts
Normal 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)));
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,28 +130,39 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
||||
return map;
|
||||
}, [visibleVersionScopeMap, visibleVersions]);
|
||||
|
||||
const risks = useMemo(() => visibleVersions.map((version) => {
|
||||
const scope = visibleVersionScopeMap[version.id];
|
||||
const workItems = workItemsByVersion.get(version.id) ?? [];
|
||||
const dailyEvidence = buildVersionDailyEvidence({
|
||||
versionId: version.id,
|
||||
workItems,
|
||||
activities,
|
||||
worklogs,
|
||||
});
|
||||
const risks = useMemo(() => {
|
||||
if (v22SummaryState === 'ready') {
|
||||
return buildXiaobaoRisksFromV22Summaries({
|
||||
summaries: filterV22XiaobaoSummariesForVisibleVersions(v22Summaries, visibleVersions),
|
||||
versions: visibleVersions,
|
||||
snapshots,
|
||||
now: calculationNow,
|
||||
});
|
||||
}
|
||||
|
||||
return calcXiaobaoVersionRisk({
|
||||
version,
|
||||
devTasks: scope?.devTasks ?? [],
|
||||
testCases: scope?.testCases ?? [],
|
||||
bugs: scope?.bugs ?? [],
|
||||
dailyEvidence,
|
||||
recentActivityCount: dailyEvidence.recentActivityCount,
|
||||
lastActivityAt: dailyEvidence.lastActivityAt,
|
||||
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
|
||||
now: calculationNow,
|
||||
});
|
||||
}).sort((a, b) => b.riskScore - a.riskScore), [
|
||||
return visibleVersions.map((version) => {
|
||||
const scope = visibleVersionScopeMap[version.id];
|
||||
const workItems = workItemsByVersion.get(version.id) ?? [];
|
||||
const dailyEvidence = buildVersionDailyEvidence({
|
||||
versionId: version.id,
|
||||
workItems,
|
||||
activities,
|
||||
worklogs,
|
||||
});
|
||||
|
||||
return calcXiaobaoVersionRisk({
|
||||
version,
|
||||
devTasks: scope?.devTasks ?? [],
|
||||
testCases: scope?.testCases ?? [],
|
||||
bugs: scope?.bugs ?? [],
|
||||
dailyEvidence,
|
||||
recentActivityCount: dailyEvidence.recentActivityCount,
|
||||
lastActivityAt: dailyEvidence.lastActivityAt,
|
||||
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
|
||||
now: calculationNow,
|
||||
});
|
||||
}).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 {
|
||||
|
||||
90
apps/web/lib/requirement-v22-query.test.ts
Normal file
90
apps/web/lib/requirement-v22-query.test.ts
Normal 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);
|
||||
});
|
||||
73
apps/web/lib/requirement-v22-query.ts
Normal file
73
apps/web/lib/requirement-v22-query.ts
Normal 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;
|
||||
}
|
||||
204
apps/web/lib/v22-api.test.ts
Normal file
204
apps/web/lib/v22-api.test.ts
Normal 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
481
apps/web/lib/v22-api.ts
Normal 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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
apps/web/lib/workspace-v22-source.test.ts
Normal file
38
apps/web/lib/workspace-v22-source.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
47
apps/web/lib/workspace-v22-source.ts
Normal file
47
apps/web/lib/workspace-v22-source.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
196
apps/web/lib/xiaobao-v22-summary.test.ts
Normal file
196
apps/web/lib/xiaobao-v22-summary.test.ts
Normal 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;
|
||||
}
|
||||
345
apps/web/lib/xiaobao-v22-summary.ts
Normal file
345
apps/web/lib/xiaobao-v22-summary.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user