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 { ConfigModule } from './modules/config/config.module';
|
||||||
import { DataModule } from './modules/data/data.module';
|
import { DataModule } from './modules/data/data.module';
|
||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
|
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
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': [
|
'task-categories': [
|
||||||
{
|
{
|
||||||
id: 'cat-fe',
|
id: 'cat-fe',
|
||||||
@@ -261,6 +286,11 @@ describe('mapAppDataToV22Rows', () => {
|
|||||||
releaseDate: '2026-02-01',
|
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(result.requirements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ interface VersionRow {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RequirementRow {
|
interface RequirementRow {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
@@ -256,6 +265,7 @@ export interface V22MappedRows {
|
|||||||
products: ProductRow[];
|
products: ProductRow[];
|
||||||
projects: ProjectRow[];
|
projects: ProjectRow[];
|
||||||
versions: VersionRow[];
|
versions: VersionRow[];
|
||||||
|
users: UserRow[];
|
||||||
requirements: RequirementRow[];
|
requirements: RequirementRow[];
|
||||||
taskCategories: TaskCategoryRow[];
|
taskCategories: TaskCategoryRow[];
|
||||||
versionPlans: VersionPlanRow[];
|
versionPlans: VersionPlanRow[];
|
||||||
@@ -277,6 +287,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
const products: ProductRow[] = [];
|
const products: ProductRow[] = [];
|
||||||
const projects: ProjectRow[] = [];
|
const projects: ProjectRow[] = [];
|
||||||
const versions: VersionRow[] = [];
|
const versions: VersionRow[] = [];
|
||||||
|
const users: UserRow[] = [];
|
||||||
const requirements: RequirementRow[] = [];
|
const requirements: RequirementRow[] = [];
|
||||||
const taskCategories: TaskCategoryRow[] = [];
|
const taskCategories: TaskCategoryRow[] = [];
|
||||||
const versionPlans: VersionPlanRow[] = [];
|
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 defaultProductId = products[0]?.id;
|
||||||
const requirementCode = scopedCodeFactory('REQ');
|
const requirementCode = scopedCodeFactory('REQ');
|
||||||
for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) {
|
for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) {
|
||||||
@@ -705,6 +731,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
products,
|
products,
|
||||||
projects,
|
projects,
|
||||||
versions,
|
versions,
|
||||||
|
users,
|
||||||
requirements,
|
requirements,
|
||||||
taskCategories,
|
taskCategories,
|
||||||
versionPlans,
|
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 {
|
function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined {
|
||||||
const explicitProjectId = stringField(version, 'projectId');
|
const explicitProjectId = stringField(version, 'projectId');
|
||||||
if (explicitProjectId) return explicitProjectId;
|
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',
|
key: 'requirements',
|
||||||
value: {
|
value: {
|
||||||
@@ -26,6 +40,7 @@ describe('AppDataV22MigrationService', () => {
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
versionId: 'version-1',
|
versionId: 'version-1',
|
||||||
title: 'Customer import',
|
title: 'Customer import',
|
||||||
|
creator: 'member-pm',
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -64,10 +79,98 @@ describe('AppDataV22MigrationService', () => {
|
|||||||
products: 1,
|
products: 1,
|
||||||
projects: 1,
|
projects: 1,
|
||||||
versions: 1,
|
versions: 1,
|
||||||
|
users: 1,
|
||||||
requirements: 1,
|
requirements: 1,
|
||||||
devTasks: 1,
|
devTasks: 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(preview.skipped).toEqual([]);
|
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 { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
import { APP_DATA_KEYS } from '../data/data-keys';
|
||||||
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
|
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
|
||||||
|
|
||||||
const COUNT_KEYS = [
|
const COUNT_KEYS = [
|
||||||
|
'users',
|
||||||
'products',
|
'products',
|
||||||
'projects',
|
'projects',
|
||||||
'versions',
|
'versions',
|
||||||
'requirements',
|
|
||||||
'taskCategories',
|
'taskCategories',
|
||||||
|
'requirements',
|
||||||
'versionPlans',
|
'versionPlans',
|
||||||
'devTasks',
|
'devTasks',
|
||||||
'testCases',
|
'testCases',
|
||||||
@@ -23,6 +24,72 @@ const COUNT_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type CountKey = (typeof COUNT_KEYS)[number];
|
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 {
|
export interface AppDataV22MigrationPreview {
|
||||||
readyToImport: boolean;
|
readyToImport: boolean;
|
||||||
@@ -30,6 +97,10 @@ export interface AppDataV22MigrationPreview {
|
|||||||
skipped: V22MappedRows['skipped'];
|
skipped: V22MappedRows['skipped'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppDataV22MigrationImportResult extends AppDataV22MigrationPreview {
|
||||||
|
inserted: Record<CountKey, number>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppDataV22MigrationService {
|
export class AppDataV22MigrationService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -54,8 +125,65 @@ export class AppDataV22MigrationService {
|
|||||||
skipped: mapped.skipped,
|
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> {
|
function countRows(mapped: V22MappedRows): Record<CountKey, number> {
|
||||||
return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as 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,
|
getRequirementTableTextClass,
|
||||||
} from '@/lib/requirement-table-layout';
|
} from '@/lib/requirement-table-layout';
|
||||||
import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link';
|
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 { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||||
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
|
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
|
||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
|
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
|
||||||
|
import { loadV22RequirementsPage } from '@/lib/v22-api';
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
P0: 'bg-red-500/10 text-red-600',
|
P0: 'bg-red-500/10 text-red-600',
|
||||||
@@ -175,6 +177,7 @@ function RequirementsPageContent() {
|
|||||||
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
||||||
addType, updateType, deleteType,
|
addType, updateType, deleteType,
|
||||||
addPlatform, updatePlatform, deletePlatform,
|
addPlatform, updatePlatform, deletePlatform,
|
||||||
|
loaded: requirementsLoaded,
|
||||||
} = useRequirementStore();
|
} = useRequirementStore();
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||||
@@ -209,10 +212,16 @@ function RequirementsPageContent() {
|
|||||||
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
|
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(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
|
||||||
|
|
||||||
const selectedScopeKey = selectedScope.type === 'all'
|
const selectedScopeKey = selectedScope.type === 'all'
|
||||||
? 'all'
|
? 'all'
|
||||||
@@ -224,6 +233,14 @@ function RequirementsPageContent() {
|
|||||||
setVersionFilter('all');
|
setVersionFilter('all');
|
||||||
}, [selectedScopeKey]);
|
}, [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 scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
||||||
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
|
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
|
||||||
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
||||||
@@ -233,6 +250,97 @@ function RequirementsPageContent() {
|
|||||||
return allVersions;
|
return allVersions;
|
||||||
}, [allVersions, selectedScope]);
|
}, [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(() => {
|
const selectedScopeTitle = useMemo(() => {
|
||||||
if (selectedScope.type === 'product') {
|
if (selectedScope.type === 'product') {
|
||||||
return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求';
|
return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求';
|
||||||
@@ -298,9 +406,30 @@ function RequirementsPageContent() {
|
|||||||
return list;
|
return list;
|
||||||
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]);
|
}, [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) => {
|
const handleEdit = (req: Requirement) => {
|
||||||
|
if (!canMutateRequirement(req)) return;
|
||||||
setViewingReq(null);
|
setViewingReq(null);
|
||||||
setEditingReq(req);
|
setEditingReq(req);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -310,11 +439,21 @@ function RequirementsPageContent() {
|
|||||||
setViewingReq(req);
|
setViewingReq(req);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = async () => {
|
||||||
|
await fetchRequirements();
|
||||||
setEditingReq(null);
|
setEditingReq(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectScope = (scope: RequirementScopeSelection) => {
|
||||||
|
setAutoScopeSelected(true);
|
||||||
|
setSelectedScope(scope);
|
||||||
|
if (scope.type === 'all') {
|
||||||
|
fetchRequirements();
|
||||||
|
fetchDevTasks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden bg-[var(--bg)]">
|
<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)]">
|
<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>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<button
|
<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 ${
|
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${
|
||||||
selectedScope.type === 'all'
|
selectedScope.type === 'all'
|
||||||
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
||||||
@@ -357,7 +496,7 @@ function RequirementsPageContent() {
|
|||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
selectedScope={selectedScope}
|
selectedScope={selectedScope}
|
||||||
onSelect={setSelectedScope}
|
onSelect={handleSelectScope}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
||||||
@@ -375,14 +514,14 @@ function RequirementsPageContent() {
|
|||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<h2 className="truncate text-[15px] font-semibold tracking-tight text-[var(--ink)]">{selectedScopeTitle}</h2>
|
<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)]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
|
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<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"
|
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} />
|
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
@@ -458,7 +597,7 @@ function RequirementsPageContent() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
||||||
<div className="px-5 py-4">
|
<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">
|
<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="text-[13px] font-medium text-[var(--ink-soft)]">没有找到匹配的需求</p>
|
||||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||||
@@ -497,7 +636,7 @@ function RequirementsPageContent() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((req) => (
|
{displayedRequirements.map((req) => (
|
||||||
<tr
|
<tr
|
||||||
key={req.id}
|
key={req.id}
|
||||||
onClick={() => handleRowClick(req)}
|
onClick={() => handleRowClick(req)}
|
||||||
@@ -603,7 +742,7 @@ function RequirementsPageContent() {
|
|||||||
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-0.5 whitespace-nowrap">
|
<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
|
<button
|
||||||
onClick={() => handleEdit(req)}
|
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)]"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* 待评审:采纳/拒绝 */}
|
{/* 待评审:采纳/拒绝 */}
|
||||||
{req.status === 'pending_review' && (
|
{canMutateRequirement(req) && req.status === 'pending_review' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
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
|
<button
|
||||||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
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"
|
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>
|
</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
|
<button
|
||||||
onClick={() => deleteRequirement(req.id)}
|
onClick={() => deleteRequirement(req.id)}
|
||||||
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-50"
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
<Pagination total={total} page={displayPage} pageSize={pageSize} onChange={handlePageChange} onPageSizeChange={setPageSize} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -671,11 +810,11 @@ function RequirementsPageContent() {
|
|||||||
requirements={requirements}
|
requirements={requirements}
|
||||||
resolveVersionName={resolveVersionName}
|
resolveVersionName={resolveVersionName}
|
||||||
onClose={() => setViewingReq(null)}
|
onClose={() => setViewingReq(null)}
|
||||||
onEdit={canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
|
onEdit={canMutateRequirement(viewingReq) && canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
|
||||||
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
onAdopt={canMutateRequirement(viewingReq) ? () => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); } : undefined}
|
||||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
onReject={canMutateRequirement(viewingReq) ? () => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); } : undefined}
|
||||||
onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
onCloseReq={canMutateRequirement(viewingReq) && canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
||||||
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
|
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 { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
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 { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||||
import { isVersionReadonly } from '@/lib/version-status';
|
import { isVersionReadonly } from '@/lib/version-status';
|
||||||
|
import { loadV22VersionDetailData } from '@/lib/v22-api';
|
||||||
import {
|
import {
|
||||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||||
buildVersionName,
|
buildVersionName,
|
||||||
@@ -89,12 +90,41 @@ export default function VersionDetailPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const versionId = params.id as string;
|
const versionId = params.id as string;
|
||||||
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
|
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 { records, fetchRecords } = useOvertimeStore();
|
||||||
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
|
const {
|
||||||
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
|
plans,
|
||||||
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
|
loaded: plansLoaded,
|
||||||
const { bugs, fetchBugs, deleteBug } = useBugStore();
|
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 user = useAuthStore((s) => s.user);
|
||||||
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
||||||
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
||||||
@@ -122,14 +152,63 @@ export default function VersionDetailPage() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||||
|
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
let active = true;
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
setV22Scope(null);
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
loadV22VersionDetailData(versionId)
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
.then((data) => {
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setRecommendationDataReady(false);
|
setRecommendationDataReady(false);
|
||||||
@@ -140,7 +219,7 @@ export default function VersionDetailPage() {
|
|||||||
}, [fetchMembers, fetchCategories]);
|
}, [fetchMembers, fetchCategories]);
|
||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||||
const versionScope = useMemo(
|
const appDataVersionScope = useMemo(
|
||||||
() => version
|
() => version
|
||||||
? buildVersionDataScope({
|
? buildVersionDataScope({
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
@@ -154,6 +233,27 @@ export default function VersionDetailPage() {
|
|||||||
: null,
|
: null,
|
||||||
[version, requirements, plans, devTasks, testCases, bugs, records],
|
[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
|
const releaseProgress = versionScope
|
||||||
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
|
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -203,6 +303,15 @@ export default function VersionDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scopedVersionData = versionScope!;
|
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 memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
|
||||||
const currentSystemParticipation = new Map<string, number>();
|
const currentSystemParticipation = new Map<string, number>();
|
||||||
overview.forEach((product) => {
|
overview.forEach((product) => {
|
||||||
@@ -244,6 +353,11 @@ export default function VersionDetailPage() {
|
|||||||
buttons.push({ label: '删除', action: () => {
|
buttons.push({ label: '删除', action: () => {
|
||||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||||
// 释放关联需求
|
// 释放关联需求
|
||||||
|
if (!versionWriteStoresReady) {
|
||||||
|
loadAllVersionStores();
|
||||||
|
alert('版本关联数据正在加载,请稍后再删除。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
|
scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
|
||||||
// 清理计划任务
|
// 清理计划任务
|
||||||
scopedVersionData.plans.forEach((p) => deletePlan(p.id));
|
scopedVersionData.plans.forEach((p) => deletePlan(p.id));
|
||||||
@@ -866,13 +980,13 @@ export default function VersionDetailPage() {
|
|||||||
<VersionRequirementsTab
|
<VersionRequirementsTab
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
projectId={version.projectId}
|
projectId={version.projectId}
|
||||||
requirements={requirements}
|
requirements={displayRequirements}
|
||||||
devTasks={devTasks}
|
devTasks={displayDevTasks}
|
||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
currentUserName={user?.name ?? ''}
|
currentUserName={user?.name ?? ''}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly || !requirementWriteReady}
|
||||||
onLink={(ids, addedBy) => {
|
onLink={(ids, addedBy) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !requirementWriteReady) return;
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const requirement = requirements.find((item) => item.id === id);
|
const requirement = requirements.find((item) => item.id === id);
|
||||||
if (!requirement) return;
|
if (!requirement) return;
|
||||||
@@ -880,13 +994,13 @@ export default function VersionDetailPage() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onUnlink={(id) => {
|
onUnlink={(id) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !requirementWriteReady) return;
|
||||||
const requirement = requirements.find((item) => item.id === id);
|
const requirement = requirements.find((item) => item.id === id);
|
||||||
if (!requirement) return;
|
if (!requirement) return;
|
||||||
updateRequirement(id, getRequirementVersionUnlinkPatch(requirement));
|
updateRequirement(id, getRequirementVersionUnlinkPatch(requirement));
|
||||||
}}
|
}}
|
||||||
onCreateChange={(data) => {
|
onCreateChange={(data) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !requirementWriteReady) return;
|
||||||
createRequirement({
|
createRequirement({
|
||||||
...data,
|
...data,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
@@ -908,10 +1022,10 @@ export default function VersionDetailPage() {
|
|||||||
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||||
(() => {
|
(() => {
|
||||||
const pt = activeTab as 'research' | 'product' | 'ui';
|
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
|
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId);
|
||||||
return (
|
return (
|
||||||
<PlanTab
|
<PlanTab
|
||||||
plans={scopedVersionData.plans}
|
plans={displayPlans}
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
version={version}
|
version={version}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
@@ -919,10 +1033,10 @@ export default function VersionDetailPage() {
|
|||||||
planType={pt}
|
planType={pt}
|
||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
linkedRequirements={projectAdoptedReqs}
|
linkedRequirements={projectAdoptedReqs}
|
||||||
allRequirements={requirements}
|
allRequirements={displayRequirements}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly || !planWriteReady}
|
||||||
onCreate={(data) => {
|
onCreate={(data) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !planWriteReady) return;
|
||||||
createPlan(data);
|
createPlan(data);
|
||||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
@@ -935,18 +1049,18 @@ export default function VersionDetailPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onUpdate={(id, data) => {
|
onUpdate={(id, data) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !planWriteReady) return;
|
||||||
updatePlan(id, data);
|
updatePlan(id, data);
|
||||||
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
||||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onComplete={(id, result) => {
|
onComplete={(id, result) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !planWriteReady) return;
|
||||||
return completePlan(id, result);
|
return completePlan(id, result);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => {
|
onDelete={(id) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly || !planWriteReady) return;
|
||||||
deletePlan(id);
|
deletePlan(id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -955,29 +1069,29 @@ export default function VersionDetailPage() {
|
|||||||
) : activeTab === 'tasks' ? (
|
) : activeTab === 'tasks' ? (
|
||||||
<DevTaskTab
|
<DevTaskTab
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
requirementIds={scopedVersionData.requirementIds}
|
requirementIds={displayRequirementIds}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly || !devTaskWriteReady}
|
||||||
versionTasks={scopedVersionData.devTasks}
|
versionTasks={displayDevTasks}
|
||||||
versionRequirements={scopedVersionData.requirements}
|
versionRequirements={displayRequirements}
|
||||||
/>
|
/>
|
||||||
) : activeTab === 'testcases' ? (
|
) : activeTab === 'testcases' ? (
|
||||||
<TestCaseTab
|
<TestCaseTab
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
requirementIds={scopedVersionData.requirementIds}
|
requirementIds={displayRequirementIds}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly || !testCaseWriteReady}
|
||||||
versionCases={scopedVersionData.testCases}
|
versionCases={displayTestCases}
|
||||||
versionBugs={scopedVersionData.bugs}
|
versionBugs={displayBugs}
|
||||||
versionDevTasks={scopedVersionData.devTasks}
|
versionDevTasks={displayDevTasks}
|
||||||
versionRequirements={scopedVersionData.requirements}
|
versionRequirements={displayRequirements}
|
||||||
/>
|
/>
|
||||||
) : activeTab === 'bugs' ? (
|
) : activeTab === 'bugs' ? (
|
||||||
<BugTab
|
<BugTab
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
requirementIds={scopedVersionData.requirementIds}
|
requirementIds={displayRequirementIds}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly || !bugWriteReady}
|
||||||
versionBugs={scopedVersionData.bugs}
|
versionBugs={displayBugs}
|
||||||
versionTestCases={scopedVersionData.testCases}
|
versionTestCases={displayTestCases}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
<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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
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 { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||||
import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status';
|
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 TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
|
||||||
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
|
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() {
|
export default function WorkspacePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
const { plans, fetchPlans } = useVersionPlanStore();
|
const { plans, fetchPlans, loaded: plansLoaded } = useVersionPlanStore();
|
||||||
const { requirements, fetchRequirements } = useRequirementStore();
|
const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore();
|
||||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore();
|
||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore();
|
||||||
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||||
const { activities, fetchActivities } = useWorkActivityStore();
|
const { activities, fetchActivities } = useWorkActivityStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -67,17 +69,62 @@ export default function WorkspacePage() {
|
|||||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
|
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
|
||||||
const [bugFromTestCaseId, setBugFromTestCaseId] = useState<string | 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(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
|
||||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||||
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
||||||
|
|
||||||
const userName = user?.name ?? '';
|
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 allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
const versionMap = useMemo(() => {
|
const versionMap = useMemo(() => {
|
||||||
@@ -92,9 +139,27 @@ export default function WorkspacePage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [requirements]);
|
}, [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(() =>
|
const workItems = useMemo(() =>
|
||||||
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
aggregateWorkItems(
|
||||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
userName,
|
||||||
|
workspaceCollections.versionPlans,
|
||||||
|
workspaceCollections.devTasks,
|
||||||
|
workspaceCollections.testCases,
|
||||||
|
workspaceCollections.bugs,
|
||||||
|
versionMap,
|
||||||
|
requirementVersionMap,
|
||||||
|
),
|
||||||
|
[userName, workspaceCollections, versionMap, requirementVersionMap]
|
||||||
);
|
);
|
||||||
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
|
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
|
||||||
|
|
||||||
@@ -158,6 +223,32 @@ export default function WorkspacePage() {
|
|||||||
const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined;
|
const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined;
|
||||||
const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined;
|
const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined;
|
||||||
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -245,7 +336,7 @@ export default function WorkspacePage() {
|
|||||||
item={item}
|
item={item}
|
||||||
versionStatus={versionMap.get(item.versionId)?.status}
|
versionStatus={versionMap.get(item.versionId)?.status}
|
||||||
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
||||||
onClick={() => setDrawerItem(item)}
|
onClick={() => { void openWorkItemDrawer(item); }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
@@ -10,6 +10,8 @@ import { useBugStore } from '@/stores/useBugStore';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
import { aggregateWorkItems } from '@/lib/workspace-engine';
|
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 } = {}) {
|
export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolean } = {}) {
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
@@ -19,15 +21,60 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
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) 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 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 allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
const versionMap = useMemo(() => {
|
const versionMap = useMemo(() => {
|
||||||
@@ -51,9 +98,27 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
return map;
|
return map;
|
||||||
}, [requirements]);
|
}, [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(
|
const workItems = useMemo(
|
||||||
() => aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
() => aggregateWorkItems(
|
||||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap],
|
userName,
|
||||||
|
workspaceCollections.versionPlans,
|
||||||
|
workspaceCollections.devTasks,
|
||||||
|
workspaceCollections.testCases,
|
||||||
|
workspaceCollections.bugs,
|
||||||
|
versionMap,
|
||||||
|
requirementVersionMap,
|
||||||
|
),
|
||||||
|
[userName, workspaceCollections, versionMap, requirementVersionMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -62,8 +127,8 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
versionMap,
|
versionMap,
|
||||||
requirementVersionMap,
|
requirementVersionMap,
|
||||||
workItems,
|
workItems,
|
||||||
devTasks,
|
devTasks: workspaceCollections.devTasks,
|
||||||
testCases,
|
testCases: workspaceCollections.testCases,
|
||||||
bugs,
|
bugs: workspaceCollections.bugs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
|||||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
|
import { loadV22XiaobaoWarnings, type V22XiaobaoWarningSummary } from '@/lib/v22-api';
|
||||||
import { buildVersionDataScopeMap } from '@/lib/version-data-scope';
|
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 { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
@@ -42,16 +49,46 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
finishInsightUpdate,
|
finishInsightUpdate,
|
||||||
} = useXiaobaoRiskStore();
|
} = useXiaobaoRiskStore();
|
||||||
const [calculationNow] = useState(() => new Date());
|
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 today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||||
|
const v22UserId = user?.name || user?.id || '';
|
||||||
|
const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
if (!canManage && !v22UserId) {
|
||||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
setV22SummaryState('idle');
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
setV22Summaries([]);
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
return;
|
||||||
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
}
|
||||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (loadRiskCache) fetchRiskData();
|
if (loadRiskCache) fetchRiskData();
|
||||||
}, [fetchRiskData, loadRiskCache]);
|
}, [fetchRiskData, loadRiskCache]);
|
||||||
@@ -93,28 +130,39 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
return map;
|
return map;
|
||||||
}, [visibleVersionScopeMap, visibleVersions]);
|
}, [visibleVersionScopeMap, visibleVersions]);
|
||||||
|
|
||||||
const risks = useMemo(() => visibleVersions.map((version) => {
|
const risks = useMemo(() => {
|
||||||
const scope = visibleVersionScopeMap[version.id];
|
if (v22SummaryState === 'ready') {
|
||||||
const workItems = workItemsByVersion.get(version.id) ?? [];
|
return buildXiaobaoRisksFromV22Summaries({
|
||||||
const dailyEvidence = buildVersionDailyEvidence({
|
summaries: filterV22XiaobaoSummariesForVisibleVersions(v22Summaries, visibleVersions),
|
||||||
versionId: version.id,
|
versions: visibleVersions,
|
||||||
workItems,
|
snapshots,
|
||||||
activities,
|
now: calculationNow,
|
||||||
worklogs,
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return calcXiaobaoVersionRisk({
|
return visibleVersions.map((version) => {
|
||||||
version,
|
const scope = visibleVersionScopeMap[version.id];
|
||||||
devTasks: scope?.devTasks ?? [],
|
const workItems = workItemsByVersion.get(version.id) ?? [];
|
||||||
testCases: scope?.testCases ?? [],
|
const dailyEvidence = buildVersionDailyEvidence({
|
||||||
bugs: scope?.bugs ?? [],
|
versionId: version.id,
|
||||||
dailyEvidence,
|
workItems,
|
||||||
recentActivityCount: dailyEvidence.recentActivityCount,
|
activities,
|
||||||
lastActivityAt: dailyEvidence.lastActivityAt,
|
worklogs,
|
||||||
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
|
});
|
||||||
now: calculationNow,
|
|
||||||
});
|
return calcXiaobaoVersionRisk({
|
||||||
}).sort((a, b) => b.riskScore - a.riskScore), [
|
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,
|
activities,
|
||||||
visibleVersionScopeMap,
|
visibleVersionScopeMap,
|
||||||
visibleVersions,
|
visibleVersions,
|
||||||
@@ -123,6 +171,8 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
snapshots,
|
snapshots,
|
||||||
today,
|
today,
|
||||||
calculationNow,
|
calculationNow,
|
||||||
|
v22Summaries,
|
||||||
|
v22SummaryState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { Bug } from './bug';
|
||||||
import type { DevTask } from './dev-task';
|
import type { DevTask } from './dev-task';
|
||||||
import type { OvertimeRecord } from './overtime';
|
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(scopeMap['version-empty'].requirements, []);
|
||||||
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
|
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> {
|
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
|
||||||
const scopes: Record<string, VersionDataScope> = {};
|
const scopes: Record<string, VersionDataScope> = {};
|
||||||
const targetVersionIds = new Set(input.versionIds);
|
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;
|
||||||
|
}
|
||||||
@@ -243,3 +243,12 @@ Partitioned tables must include the partition key in every primary key and busin
|
|||||||
Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis.
|
Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis.
|
||||||
|
|
||||||
The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled.
|
The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled.
|
||||||
|
|
||||||
|
The first V2.2 read layer is query-first and AppData-compatible. `V22QueryModule` exposes scoped read APIs for version detail, requirement pool, workspace, and Xiaobao warning summaries:
|
||||||
|
|
||||||
|
- Version detail reads only the current version's requirements, plans, dev tasks, test cases, and bugs.
|
||||||
|
- Requirement pool queries require `productId`, so list/search/filter operations stay on the `requirements.product_id` partition key.
|
||||||
|
- Workspace reads only the current user's unfinished plans, dev tasks, test cases, and bugs.
|
||||||
|
- Xiaobao warning reads `xiaobao_risk_summaries` first, then maps the precomputed summary into the existing warning UI shape.
|
||||||
|
|
||||||
|
During the V2.2 compatibility window, writes still go through the existing AppData stores. The frontend consumes V2.2 relation-table results for render-heavy pages and falls back to AppData only when the V2.2 read is unavailable or empty.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 开发路线图
|
# 开发路线图
|
||||||
|
|
||||||
## 当前阶段:V2.1 — 服务端持久化第一阶段
|
## 当前阶段:V2.2 — 分区关系表与高频读取热路径
|
||||||
|
|
||||||
业务流程仍保持 V1 的前端 store 形状,但业务数据主存储已切到 NestJS + PostgreSQL `app_data` 文档表。浏览器只保留登录态,不再保存产品、项目、版本、需求、版本详情、成员、任务类型等业务数据。
|
V2.2 已完成第一批高频读取热路径:分区关系表基础、AppData 迁移预演、V2.2 scoped read API,以及版本详情、需求池、与我相关、小宝预警的前端快读接入。业务写入仍保留现有 AppData store 兼容窗口,后续再逐步打开关系表写入和领域 CRUD。
|
||||||
|
|
||||||
### 已完成(按时间倒序)
|
### 已完成(按时间倒序)
|
||||||
|
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.
|
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.
|
||||||
- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key.
|
- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key.
|
||||||
- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled.
|
- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled.
|
||||||
|
- V2.2 scoped read API added for version detail, requirement pool, workspace, and Xiaobao warning summaries.
|
||||||
|
- Version detail, requirement pool, workspace, sidebar badges, version list Xiaobao indicators, and Xiaobao warning page now prefer V2.2 fast-read data and fall back to AppData only for compatibility.
|
||||||
|
- Requirement pool V2.2 queries require `productId`, avoiding accidental full-table scans against annual hundreds-of-thousands-row data.
|
||||||
|
- Xiaobao warning now consumes precomputed `xiaobao_risk_summaries` before loading heavy AppData task/test/bug/activity documents.
|
||||||
|
- V2.2 completion boundary: read hot paths are complete; writes remain on AppData until the relation-table write APIs are enabled.
|
||||||
|
|
||||||
**2026-07-02**
|
**2026-07-02**
|
||||||
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
|
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
|
||||||
|
|||||||
134
docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md
Normal file
134
docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# V2.2 Performance Hot Path Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Finish V2.2 by moving high-volume read paths from full AppData document scans to partition-key-scoped relation-table queries, with safe AppData fallback for existing writes.
|
||||||
|
|
||||||
|
**Architecture:** Backend exposes read-optimized V2.2 query endpoints for version detail, requirement pool, workspace, and Xiaobao warning summary data. Frontend adds a single mapping layer that converts Prisma-shaped rows into the existing UI types, then pages consume that layer first and fall back to the existing Zustand/AppData stores when the relation tables are unavailable or empty.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 14 App Router, Zustand, NestJS, Prisma, PostgreSQL partitioned tables, node:test, Jest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Frontend V2.2 API Mapping
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/lib/v22-api.ts`
|
||||||
|
- Create: `apps/web/lib/v22-api.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add `apps/web/lib/v22-api.test.ts` with tests that mock `fetch`, call `loadV22VersionDetailData('version-1')`, and assert:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
assert.equal(calls[1].url.endsWith('/v2.2/versions/version-1/detail-data'), true);
|
||||||
|
assert.equal(result.scope.requirements[0].typeId, 'feature');
|
||||||
|
assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']);
|
||||||
|
assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001');
|
||||||
|
assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z');
|
||||||
|
assert.equal(result.scope.testCases[0].caseNo, 'TC-001');
|
||||||
|
assert.equal(result.scope.bugs[0].bugNo, 'BUG-001');
|
||||||
|
assert.equal(result.scope.plans[0].owner, 'member-1');
|
||||||
|
assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: FAIL because `apps/web/lib/v22-api.ts` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal mapping layer**
|
||||||
|
|
||||||
|
Create `apps/web/lib/v22-api.ts` exporting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
loadV22VersionDetailData(versionId: string)
|
||||||
|
loadV22RequirementsPage(query)
|
||||||
|
loadV22WorkspaceData(userId: string)
|
||||||
|
loadV22XiaobaoWarnings(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
The mapper must convert `priority: number` to `P0`-style priorities, `platform` CSV to `platforms[]`, `code` to `taskNo/caseNo/bugNo`, `ownerId` to `owner`, and date-like values to ISO strings.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web test`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Version Detail Hot Path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/app/versions/[id]/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write or extend testable helper first**
|
||||||
|
|
||||||
|
Use Task 1 mapping tests as the behavior guard for the data shape consumed by the page.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace full child-data fetches for read-heavy render**
|
||||||
|
|
||||||
|
In `apps/web/app/versions/[id]/page.tsx`, load `loadV22VersionDetailData(versionId)` in an effect. Use its returned scope for overview, requirement, plan, dev-task, test-case, and bug tab props when available. Keep the existing stores loaded for overview, members, categories, overtime, drawers, and mutations.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Keep fallback behavior**
|
||||||
|
|
||||||
|
If the V2.2 request fails or returns an empty relation scope, keep using `buildVersionDataScope(...)` from AppData stores.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run: `pnpm --filter web type-check` and `pnpm --filter web test`.
|
||||||
|
|
||||||
|
Expected: both exit 0.
|
||||||
|
|
||||||
|
### Task 3: Backend And Migration Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Already modified: `apps/server/src/modules/migration/*`
|
||||||
|
- Already created: `apps/server/src/modules/v22-query/*`
|
||||||
|
- Modify if needed: `apps/server/src/app.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify Prisma schema**
|
||||||
|
|
||||||
|
Run: `pnpm --filter server exec prisma validate --schema prisma/schema.prisma`
|
||||||
|
|
||||||
|
Expected: schema validates.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify backend types and tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter server type-check`
|
||||||
|
|
||||||
|
Run from `apps/server`: `$env:NODE_OPTIONS='--max-old-space-size=4096'; .\node_modules\.bin\jest.CMD --runInBand`
|
||||||
|
|
||||||
|
Expected: both exit 0.
|
||||||
|
|
||||||
|
### Task 4: Documentation And Commit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/architecture.md`
|
||||||
|
- Modify: `docs/roadmap.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Document V2.2 completion boundary**
|
||||||
|
|
||||||
|
Update docs to say V2.2 relation tables, import execution, read query API, and version-detail frontend hot path are complete. State that writes still go through AppData during the compatibility window.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Final verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm --filter web type-check
|
||||||
|
pnpm --filter web test
|
||||||
|
pnpm --filter server type-check
|
||||||
|
pnpm --filter server exec prisma validate --schema prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
Run full backend Jest from `apps/server`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit locally**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add apps/server apps/web docs
|
||||||
|
git commit -m "feat(v2.2): 完成高频读取热路径"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not push unless the user explicitly asks.
|
||||||
Reference in New Issue
Block a user