Compare commits
12 Commits
32aaf53b26
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e595c7e72 | ||
|
|
1e6fb0c7aa | ||
|
|
6d2999085e | ||
|
|
c724446bbd | ||
|
|
56c8f59d13 | ||
|
|
7cd18dabac | ||
|
|
889a34cfa1 | ||
|
|
89ee44422b | ||
|
|
04db0000b5 | ||
|
|
b6b7ebf44f | ||
|
|
74c55df59b | ||
|
|
5933b84bf6 |
10
.superpowers/sdd/progress.md
Normal file
10
.superpowers/sdd/progress.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Business Analysis Agent implementation progress
|
||||||
|
|
||||||
|
Task 1: complete (commit b6b7ebf, shared contracts)
|
||||||
|
Task 2: complete (thread 019f41cd-e0f5-7e21-b8b3-f5d1c5d398c2, source commit 773505b, integrated commit 04db000)
|
||||||
|
Task 3: complete (thread 019f41cd-e105-7073-9fb6-867f3b37364e, source commit a6ef8e3, integrated commit 889a34c)
|
||||||
|
Task 6: complete (thread 019f41cd-e1a4-7b01-aa8a-a583820507ab, source commit f1958de, integrated commit 89ee444)
|
||||||
|
Task 4: complete (thread 019f41dd-baa5-78d3-8fd6-b82da2896c12, source commit 82fe7b4, integrated commit 7cd18da)
|
||||||
|
Task 5: complete (thread 019f41f2-5597-70b3-a40a-e284e147988f, source commits 11a1009+a83c6f2, integrated commits 56c8f59+c724446, review approved)
|
||||||
|
Task 7: complete (thread 019f4211-0692-7c32-bfe0-87513154b7e5, source commit 5cb2e57, integrated commit 6d29990, review approved)
|
||||||
|
Task 8: complete (thread 019f4482-1902-76a0-ae84-0cbbefa68127, source commit 8f125fb, integrated commit 1e6fb0c, review approved)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "requirements" ADD COLUMN "product_owner_id" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "requirements"
|
||||||
|
ADD CONSTRAINT "requirements_product_owner_id_fkey"
|
||||||
|
FOREIGN KEY ("product_owner_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "requirements_product_owner_created_at_idx"
|
||||||
|
ON "requirements"("product_owner_id", "created_at" DESC);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
WITH raw_members AS (
|
||||||
|
SELECT member
|
||||||
|
FROM app_data
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(value -> 'members') = 'array' THEN value -> 'members'
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
) AS member
|
||||||
|
WHERE key = 'members'
|
||||||
|
),
|
||||||
|
members AS (
|
||||||
|
SELECT
|
||||||
|
NULLIF(member ->> 'id', '') AS id,
|
||||||
|
NULLIF(member ->> 'username', '') AS username,
|
||||||
|
NULLIF(member ->> 'departmentId', '') AS department_id,
|
||||||
|
NULLIF(member ->> 'roleId', '') AS role_id,
|
||||||
|
COALESCE(member ->> 'phone', '') AS phone,
|
||||||
|
COALESCE(member ->> 'password', '') AS password,
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(member -> 'isSystem') = 'boolean' THEN (member ->> 'isSystem')::boolean
|
||||||
|
ELSE NULL
|
||||||
|
END AS is_system,
|
||||||
|
COUNT(*) OVER (PARTITION BY NULLIF(member ->> 'username', '')) AS username_count
|
||||||
|
FROM raw_members
|
||||||
|
)
|
||||||
|
UPDATE users AS u
|
||||||
|
SET
|
||||||
|
username = CASE
|
||||||
|
WHEN NULLIF(u.username, '') IS NULL
|
||||||
|
AND m.username IS NOT NULL
|
||||||
|
AND m.username_count = 1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users AS other_user
|
||||||
|
WHERE other_user.id <> u.id
|
||||||
|
AND other_user.username = m.username
|
||||||
|
)
|
||||||
|
THEN m.username
|
||||||
|
ELSE u.username
|
||||||
|
END,
|
||||||
|
department_id = COALESCE(m.department_id, u.department_id),
|
||||||
|
role_id = COALESCE(m.role_id, u.role_id, 'member'),
|
||||||
|
phone = COALESCE(m.phone, u.phone, ''),
|
||||||
|
password = COALESCE(m.password, u.password, ''),
|
||||||
|
is_system = COALESCE(m.is_system, u.is_system, false)
|
||||||
|
FROM members AS m
|
||||||
|
WHERE u.id = m.id;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "version_plans"
|
||||||
|
ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]';
|
||||||
@@ -24,7 +24,8 @@ model User {
|
|||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
projectMembers ProjectMember[]
|
projectMembers ProjectMember[]
|
||||||
requirements Requirement[]
|
requirements Requirement[] @relation("RequirementCreator")
|
||||||
|
productOwnedRequirements Requirement[] @relation("RequirementProductOwner")
|
||||||
watchedTasks TaskWatcher[]
|
watchedTasks TaskWatcher[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -116,11 +117,13 @@ model Requirement {
|
|||||||
sourceTarget String? @map("source_target")
|
sourceTarget String? @map("source_target")
|
||||||
platform String?
|
platform String?
|
||||||
creatorId String? @map("creator_id")
|
creatorId String? @map("creator_id")
|
||||||
|
productOwnerId String? @map("product_owner_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
creator User? @relation(fields: [creatorId], references: [id])
|
creator User? @relation("RequirementCreator", fields: [creatorId], references: [id])
|
||||||
|
productOwner User? @relation("RequirementProductOwner", fields: [productOwnerId], references: [id])
|
||||||
|
|
||||||
@@id([id, productId])
|
@@id([id, productId])
|
||||||
@@unique([productId, code])
|
@@unique([productId, code])
|
||||||
@@ -290,6 +293,7 @@ model VersionPlan {
|
|||||||
actualStartAt DateTime? @map("actual_start_at")
|
actualStartAt DateTime? @map("actual_start_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
resultUrl String? @map("result_url")
|
resultUrl String? @map("result_url")
|
||||||
|
tasks Json @default("[]")
|
||||||
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
||||||
logs Json @default("[]")
|
logs Json @default("[]")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ describe('PermissionService', () => {
|
|||||||
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves configured role permissions for the current user', async () => {
|
||||||
|
appDataFindUnique.mockResolvedValue({
|
||||||
|
value: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role-dev', permissions: ['project:view'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.resolveUserPermissions(currentUser({ roleId: 'role-dev' }))).resolves.toEqual(['project:view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no permissions for anonymous users', async () => {
|
||||||
|
await expect(service.resolveUserPermissions(null)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ const VERSION_WORK_MUTATION_PERMISSIONS = new Set([
|
|||||||
export class PermissionService {
|
export class PermissionService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveUserPermissions(user: CurrentUser | null | undefined): Promise<string[]> {
|
||||||
|
if (!user) return [];
|
||||||
|
return this.resolveRolePermissions(user.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
|
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
||||||
|
|||||||
25
apps/server/src/common/user-reference.ts
Normal file
25
apps/server/src/common/user-reference.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export async function resolveUserReference(prisma: any, reference: string | null | undefined): Promise<string | null> {
|
||||||
|
const normalized = emptyToNull(reference);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (!prisma?.user?.findFirst) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: normalized },
|
||||||
|
{ name: normalized },
|
||||||
|
{ username: normalized },
|
||||||
|
{ email: normalized },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToNull(value: string | null | undefined): string | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (value === undefined) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { AiController } from './ai.controller';
|
||||||
|
|
||||||
|
describe('AiController', () => {
|
||||||
|
it('uses server-derived permissions for analysis instead of body permissions', async () => {
|
||||||
|
const aiService = {};
|
||||||
|
const businessAnalysisService = {
|
||||||
|
analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }),
|
||||||
|
};
|
||||||
|
const authContext = {
|
||||||
|
resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }),
|
||||||
|
};
|
||||||
|
const permissionService = {
|
||||||
|
resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']),
|
||||||
|
};
|
||||||
|
const controller = new (AiController as any)(
|
||||||
|
aiService,
|
||||||
|
businessAnalysisService,
|
||||||
|
authContext,
|
||||||
|
permissionService,
|
||||||
|
) as AiController;
|
||||||
|
const request = { headers: { 'x-ftb-user-id': 'm-1' } };
|
||||||
|
|
||||||
|
await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request);
|
||||||
|
|
||||||
|
expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request);
|
||||||
|
expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' });
|
||||||
|
expect(businessAnalysisService.analyze).toHaveBeenCalledWith(
|
||||||
|
{ question: '分析版本风险', permissions: ['*'] },
|
||||||
|
{ id: 'm-1', permissions: ['project:view'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post, Req } from '@nestjs/common';
|
||||||
|
import { AuthContextService, type AuthenticatedRequest } from '../../common/auth/auth-context.service';
|
||||||
|
import { PermissionService } from '../../common/auth/permission.service';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
|
import { AnalysisDto } from './dto/analysis.dto';
|
||||||
import { DecomposeDto } from './dto/decompose.dto';
|
import { DecomposeDto } from './dto/decompose.dto';
|
||||||
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
||||||
import type {
|
import type {
|
||||||
@@ -7,11 +11,17 @@ import type {
|
|||||||
AgentDecomposeError,
|
AgentDecomposeError,
|
||||||
AgentRiskInterpretResponse,
|
AgentRiskInterpretResponse,
|
||||||
AgentRiskInterpretError,
|
AgentRiskInterpretError,
|
||||||
|
AnalysisResponse,
|
||||||
} from '@ftb/shared';
|
} from '@ftb/shared';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
constructor(private readonly aiService: AiService) {}
|
constructor(
|
||||||
|
private readonly aiService: AiService,
|
||||||
|
private readonly businessAnalysisService: BusinessAnalysisService,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
private readonly permissionService: PermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('decompose')
|
@Post('decompose')
|
||||||
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
||||||
@@ -22,4 +32,17 @@ export class AiController {
|
|||||||
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||||
return this.aiService.interpretRisk(dto);
|
return this.aiService.interpretRisk(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('analysis')
|
||||||
|
async analyze(
|
||||||
|
@Body() dto: AnalysisDto,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
): Promise<AnalysisResponse> {
|
||||||
|
const user = await this.authContext.resolveCurrentUser(request);
|
||||||
|
const permissions = await this.permissionService.resolveUserPermissions(user);
|
||||||
|
return this.businessAnalysisService.analyze(dto, {
|
||||||
|
id: user?.id ?? '',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,24 @@ import { AiController } from './ai.controller';
|
|||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { AiGatewayService } from './ai-gateway.service';
|
import { AiGatewayService } from './ai-gateway.service';
|
||||||
import { ConfigModule } from '../config/config.module';
|
import { ConfigModule } from '../config/config.module';
|
||||||
|
import { AuthModule } from '../../common/auth/auth.module';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
|
import { MetricEngine } from './analysis/metric-engine';
|
||||||
|
import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
|
||||||
|
import { AnalysisReportBuilder } from './analysis/report-builder';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule, AuthModule, CommonDomainModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [AiService, AiGatewayService],
|
providers: [
|
||||||
|
AiService,
|
||||||
|
AiGatewayService,
|
||||||
|
BusinessAnalysisService,
|
||||||
|
PermissionScopeResolver,
|
||||||
|
MetricEngine,
|
||||||
|
AnalysisReportBuilder,
|
||||||
|
],
|
||||||
exports: [AiService],
|
exports: [AiService],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
|
|||||||
@@ -271,6 +271,35 @@ describe('AiService', () => {
|
|||||||
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a readable timeout error when decomposition provider request is aborted', async () => {
|
||||||
|
const callTool = jest.fn().mockRejectedValue(new Error('signal is aborted without reason'));
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool,
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
(service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001:手机号登录');
|
||||||
|
|
||||||
|
const result = await service.decompose({
|
||||||
|
prototypeUrl: 'https://example.com/prototype',
|
||||||
|
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
|
||||||
|
members: [{ name: '张三', role: 'frontend' }],
|
||||||
|
versionId: 'version-1',
|
||||||
|
planId: 'plan-1',
|
||||||
|
target: 'dev_tasks',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'API_TIMEOUT',
|
||||||
|
error: expect.stringContaining('AI 服务调用超时'),
|
||||||
|
});
|
||||||
|
expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expect.any(Number) }));
|
||||||
|
expect(result.ok ? '' : result.error).not.toContain('signal is aborted without reason');
|
||||||
|
});
|
||||||
|
|
||||||
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
||||||
const callTool = jest
|
const callTool = jest
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
} from '@ftb/shared';
|
} from '@ftb/shared';
|
||||||
|
|
||||||
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
||||||
|
const DECOMPOSE_AI_TIMEOUT_MS = 110000;
|
||||||
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
||||||
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
||||||
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
||||||
@@ -97,6 +98,7 @@ export class AiService {
|
|||||||
forceTool: true,
|
forceTool: true,
|
||||||
maxTokens: 16000,
|
maxTokens: 16000,
|
||||||
model,
|
model,
|
||||||
|
timeoutMs: DECOMPOSE_AI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -108,6 +110,15 @@ export class AiService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isProviderTimeoutError(e)) {
|
||||||
|
this.logger.warn(`AI 拆解调用超时: ${e.message}`);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `AI 服务调用超时:上游模型在${this.formatTimeout(DECOMPOSE_AI_TIMEOUT_MS)}内未返回拆解结果,请稍后重试,或在 AI 配置中切换更快/更稳定的提供商。`,
|
||||||
|
code: 'API_TIMEOUT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.error(`AI 调用失败: ${e.message}`);
|
this.logger.error(`AI 调用失败: ${e.message}`);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -267,6 +278,17 @@ export class AiService {
|
|||||||
return message.includes('tool_call arguments') && message.includes('JSON');
|
return message.includes('tool_call arguments') && message.includes('JSON');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isProviderTimeoutError(error: any): boolean {
|
||||||
|
const name = String(error?.name || '');
|
||||||
|
const message = String(error?.message || error || '');
|
||||||
|
return /timeout|timed out|aborted|abort/i.test(`${name} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTimeout(ms: number): string {
|
||||||
|
if (ms >= 60000) return `约 ${Math.ceil(ms / 60000)} 分钟`;
|
||||||
|
return `${Math.ceil(ms / 1000)} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPrototype(url: string): Promise<string> {
|
private async fetchPrototype(url: string): Promise<string> {
|
||||||
if (!url || !url.startsWith('http')) {
|
if (!url || !url.startsWith('http')) {
|
||||||
throw new Error('原型链接无效');
|
throw new Error('原型链接无效');
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
|
||||||
|
describe('analysis plan processor', () => {
|
||||||
|
it('normalizes trend plans without a time range to last 30 days', () => {
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: 'requirement_completion_count',
|
||||||
|
analysisType: 'trend',
|
||||||
|
dimensions: ['day'],
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
|
||||||
|
expect(plan.timeRange).toEqual({
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps current-state risk plans without a time range', () => {
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['version'],
|
||||||
|
scope: { type: 'managed_projects', projectIds: ['project-1'] },
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan.timeRange).toBeUndefined();
|
||||||
|
expect(plan.limit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported metric dimensions', () => {
|
||||||
|
const plan = normalizeAnalysisPlan({
|
||||||
|
metricId: 'bug_severity_count',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['department'],
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => validateAnalysisPlan(plan)).toThrow(
|
||||||
|
'Unsupported dimension department for metric bug_severity_count',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
|
||||||
|
export interface AnalysisPlanDraft {
|
||||||
|
metricId: MetricId;
|
||||||
|
metricVersion?: number;
|
||||||
|
analysisType: AnalysisType;
|
||||||
|
dimensions: DimensionId[];
|
||||||
|
scope: DataScope;
|
||||||
|
filters?: Record<string, string | number | boolean | string[] | number[]>;
|
||||||
|
timeRange?: { start: string; end: string; policy?: TimePolicy };
|
||||||
|
limit?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TOP_N = 20;
|
||||||
|
const DEFAULT_TOP_N = 10;
|
||||||
|
|
||||||
|
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
|
||||||
|
const metric = getMetricDefinition(input.metricId, input.metricVersion);
|
||||||
|
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
|
||||||
|
|
||||||
|
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
|
||||||
|
const timeRange = input.timeRange
|
||||||
|
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
|
||||||
|
: needsDefaultTime
|
||||||
|
? last30Days(now)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: { metricId: metric.metricId, version: metric.version },
|
||||||
|
analysisType: input.analysisType,
|
||||||
|
dimensions: input.dimensions.length > 0
|
||||||
|
? input.dimensions
|
||||||
|
: metric.defaultDimension
|
||||||
|
? [metric.defaultDimension]
|
||||||
|
: [],
|
||||||
|
filters: input.filters ?? {},
|
||||||
|
scope: input.scope,
|
||||||
|
...(timeRange ? { timeRange } : {}),
|
||||||
|
limit: normalizeLimit(input.limit, input.analysisType),
|
||||||
|
...(input.sort ? { sort: input.sort } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAnalysisPlan(plan: AnalysisPlan): void {
|
||||||
|
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||||||
|
if (!metric || metric.status !== 'active') {
|
||||||
|
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
|
||||||
|
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
for (const dimension of plan.dimensions) {
|
||||||
|
if (!metric.supportedDimensions.includes(dimension)) {
|
||||||
|
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
|
||||||
|
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
|
||||||
|
}
|
||||||
|
if (plan.timeRange) {
|
||||||
|
const start = Date.parse(plan.timeRange.start);
|
||||||
|
const end = Date.parse(plan.timeRange.end);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
|
||||||
|
throw new BadRequestException('Invalid time range');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
|
||||||
|
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
|
||||||
|
const value = limit ?? DEFAULT_TOP_N;
|
||||||
|
return Math.min(Math.max(1, value), MAX_TOP_N);
|
||||||
|
}
|
||||||
|
|
||||||
|
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setUTCHours(23, 59, 59, 999);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setUTCDate(start.getUTCDate() - 29);
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
policy: 'last_30_days',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||||||
|
|
||||||
|
describe('analysis semantic layer', () => {
|
||||||
|
it('maps busy wording to workload with high confidence', () => {
|
||||||
|
expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
|
||||||
|
concept: 'workload',
|
||||||
|
semanticConfidence: 'high',
|
||||||
|
metricId: 'department_workload',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps pressure wording to workload pressure with medium confidence', () => {
|
||||||
|
expect(parseSemanticIntent('谁压力最大')).toMatchObject({
|
||||||
|
concept: 'work_pressure',
|
||||||
|
semanticConfidence: 'medium',
|
||||||
|
metricId: 'member_pending_work',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps project risk as current-state release risk', () => {
|
||||||
|
expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
|
||||||
|
concept: 'release_risk',
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
timePolicy: 'current_state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';
|
||||||
|
|
||||||
|
export type SemanticConcept =
|
||||||
|
| 'workload'
|
||||||
|
| 'work_pressure'
|
||||||
|
| 'release_risk'
|
||||||
|
| 'delay'
|
||||||
|
| 'delivery_efficiency'
|
||||||
|
| 'quality_risk'
|
||||||
|
| 'requirement_completion'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export interface SemanticIntent {
|
||||||
|
concept: SemanticConcept;
|
||||||
|
metricId: MetricId;
|
||||||
|
analysisType: AnalysisType;
|
||||||
|
timePolicy: TimePolicy;
|
||||||
|
semanticConfidence: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSemanticIntent(question: string): SemanticIntent {
|
||||||
|
const text = question.trim().toLowerCase();
|
||||||
|
if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
|
||||||
|
return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(压力|压着|吃紧)/.test(text)) {
|
||||||
|
return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
|
||||||
|
}
|
||||||
|
if (/(忙|负载|待办|任务最多)/.test(text)) {
|
||||||
|
const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text)
|
||||||
|
? 'department_workload'
|
||||||
|
: 'member_pending_work';
|
||||||
|
return intent('workload', metricId, 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(延期|逾期|超期)/.test(text)) {
|
||||||
|
return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
|
||||||
|
return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
|
||||||
|
}
|
||||||
|
if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
|
||||||
|
const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
|
||||||
|
const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
|
||||||
|
return intent(
|
||||||
|
'quality_risk',
|
||||||
|
metricId,
|
||||||
|
analysisType,
|
||||||
|
metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state',
|
||||||
|
'high',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/(加班|投入|工时)/.test(text)) {
|
||||||
|
return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
|
||||||
|
}
|
||||||
|
return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
|
||||||
|
}
|
||||||
|
|
||||||
|
function intent(
|
||||||
|
concept: SemanticConcept,
|
||||||
|
metricId: MetricId,
|
||||||
|
analysisType: AnalysisType,
|
||||||
|
timePolicy: TimePolicy,
|
||||||
|
semanticConfidence: 'high' | 'medium' | 'low',
|
||||||
|
): SemanticIntent {
|
||||||
|
return { concept, metricId, analysisType, timePolicy, semanticConfidence };
|
||||||
|
}
|
||||||
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal file
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AnalysisPlan, AnalysisRequest, DimensionId } from '@ftb/shared';
|
||||||
|
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||||||
|
import type { SemanticIntent } from './analysis-semantic-layer';
|
||||||
|
import { normalizeAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
|
||||||
|
export function createAnalysisPlanFromQuestion(
|
||||||
|
request: AnalysisRequest,
|
||||||
|
scope: AnalysisPlan['scope'],
|
||||||
|
now = new Date(),
|
||||||
|
): { semantic: SemanticIntent; plan: AnalysisPlan } {
|
||||||
|
const semantic = parseSemanticIntent(request.question);
|
||||||
|
const dimensions = inferDimensions(request.question, semantic.metricId);
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: semantic.metricId,
|
||||||
|
analysisType: semantic.analysisType,
|
||||||
|
dimensions,
|
||||||
|
scope,
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return { semantic, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDimensions(
|
||||||
|
question: string,
|
||||||
|
metricId: AnalysisPlan['metricRef']['metricId'],
|
||||||
|
): DimensionId[] {
|
||||||
|
const metric = getMetricDefinition(metricId);
|
||||||
|
const preferred = inferPreferredDimension(question, metricId);
|
||||||
|
if (preferred && metric?.supportedDimensions.includes(preferred)) return [preferred];
|
||||||
|
if (metric?.defaultDimension) return [metric.defaultDimension];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPreferredDimension(
|
||||||
|
question: string,
|
||||||
|
metricId: AnalysisPlan['metricRef']['metricId'],
|
||||||
|
): DimensionId | null {
|
||||||
|
if (/部门/.test(question)) return 'department';
|
||||||
|
if (/成员|谁|负责人/.test(question)) return 'member';
|
||||||
|
if (/产品/.test(question)) return 'product';
|
||||||
|
if (/项目/.test(question)) return 'project';
|
||||||
|
if (/月|月份/.test(question)) return 'month';
|
||||||
|
if (/周/.test(question)) return 'week';
|
||||||
|
if (metricId === 'version_risk_score') return 'version';
|
||||||
|
if (metricId === 'bug_severity_count') return 'bug_severity';
|
||||||
|
if (metricId === 'requirement_status_count') return 'requirement_status';
|
||||||
|
if (metricId === 'requirement_source_count') return 'requirement_source';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { BusinessAnalysisService } from './business-analysis.service';
|
||||||
|
|
||||||
|
describe('BusinessAnalysisService', () => {
|
||||||
|
function makeService() {
|
||||||
|
const scopeResolver = {
|
||||||
|
resolveAnalysisScope: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ type: 'version', versionId: 'ver-1' }),
|
||||||
|
};
|
||||||
|
const metricEngine = {
|
||||||
|
executeMetric: jest.fn().mockResolvedValue({
|
||||||
|
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
columns: [{ id: 'label', label: '版本', type: 'string' }],
|
||||||
|
rows: [{ label: 'V1', value: 88 }],
|
||||||
|
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||||||
|
dataScope: { type: 'version', versionId: 'ver-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const reportBuilder = {
|
||||||
|
build: jest.fn().mockResolvedValue({
|
||||||
|
summary: 'V1 风险较高。',
|
||||||
|
keyFindings: ['风险分 88。'],
|
||||||
|
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||||||
|
suggestions: ['优先处理阻塞和严重 Bug。'],
|
||||||
|
dataScope: {
|
||||||
|
timeDescription: '当前状态',
|
||||||
|
permissionDescription: '当前版本',
|
||||||
|
metricFormulaDescription: '小宝风险分',
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new BusinessAnalysisService(
|
||||||
|
scopeResolver as any,
|
||||||
|
metricEngine as any,
|
||||||
|
reportBuilder as any,
|
||||||
|
);
|
||||||
|
return { service, scopeResolver, metricEngine, reportBuilder };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns insight, chart, report, evidence, and follow-ups', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
const result = await service.analyze(
|
||||||
|
{
|
||||||
|
question: '这个版本风险怎么样',
|
||||||
|
context: { surface: 'version_detail', versionId: 'ver-1' },
|
||||||
|
},
|
||||||
|
{ id: 'm-1', permissions: [] },
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.chart.kind).toBe('horizontal_bar');
|
||||||
|
expect(result.insight.summary).toContain('V1');
|
||||||
|
expect(result.report.summary).toContain('风险');
|
||||||
|
expect(
|
||||||
|
result.followUps.some(
|
||||||
|
(item: { type: string }) => item.type === 'question',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NO_DATA when metric result has no rows', async () => {
|
||||||
|
const { service, metricEngine } = makeService();
|
||||||
|
metricEngine.executeMetric.mockResolvedValueOnce({
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
analysisType: 'trend',
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
|
||||||
|
dataScope: { type: 'self', userId: 'm-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.analyze(
|
||||||
|
{ question: '需求完成趋势' },
|
||||||
|
{ id: 'm-1', permissions: [] },
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal file
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
import { PermissionScopeResolver } from './permission-scope-resolver';
|
||||||
|
import { MetricEngine } from './metric-engine';
|
||||||
|
import { AnalysisReportBuilder } from './report-builder';
|
||||||
|
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
|
||||||
|
import { validateAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
import { buildUnifiedChartSpec } from './chart-spec-builder';
|
||||||
|
import { buildInsightCard } from './insight-engine';
|
||||||
|
import { buildFollowUps } from './follow-up-builder';
|
||||||
|
|
||||||
|
export interface AnalysisActor {
|
||||||
|
id: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusinessAnalysisService {
|
||||||
|
constructor(
|
||||||
|
private readonly scopeResolver: PermissionScopeResolver,
|
||||||
|
private readonly metricEngine: MetricEngine,
|
||||||
|
private readonly reportBuilder: AnalysisReportBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async analyze(
|
||||||
|
request: AnalysisRequest,
|
||||||
|
actor: AnalysisActor,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AnalysisResponse> {
|
||||||
|
const question = request.question.trim();
|
||||||
|
if (!question) {
|
||||||
|
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope;
|
||||||
|
try {
|
||||||
|
scope = await this.scopeResolver.resolveAnalysisScope({
|
||||||
|
actorId: actor.id,
|
||||||
|
permissions: actor.permissions,
|
||||||
|
context: request.context,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) {
|
||||||
|
return { ok: false, code: 'NO_PERMISSION', message: '当前用户没有该范围的分析权限。' };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { semantic, plan } = createAnalysisPlanFromQuestion({ ...request, question }, scope, now);
|
||||||
|
if (semantic.semanticConfidence === 'low') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'AMBIGUOUS_INTENT',
|
||||||
|
message: '这个问题有多种理解,请选择一个分析方向。',
|
||||||
|
clarificationOptions: [
|
||||||
|
{ label: '成员负载', prompt: '分析成员待办排行' },
|
||||||
|
{ label: '版本风险', prompt: '分析版本风险排行' },
|
||||||
|
],
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateAnalysisPlan(plan);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadRequestException) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_PLAN',
|
||||||
|
message: error.message,
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||||||
|
if (!metric) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'UNSUPPORTED_ANALYSIS',
|
||||||
|
message: '当前指标不在分析目录中。',
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricResult = await this.metricEngine.executeMetric(plan, now);
|
||||||
|
if (metricResult.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'NO_DATA',
|
||||||
|
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
|
||||||
|
const chart = buildUnifiedChartSpec(metricResult, metric);
|
||||||
|
const report = await this.reportBuilder.build(metricResult, insight, metric, plan);
|
||||||
|
const followUps = buildFollowUps(metricResult, plan);
|
||||||
|
return { ok: true, plan, metricResult, insight, chart, report, followUps };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal file
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MetricDefinition, MetricResult, UnifiedChartSpec } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildUnifiedChartSpec(
|
||||||
|
result: MetricResult,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
): UnifiedChartSpec {
|
||||||
|
const labelColumn = result.columns.find((column) => column.type === 'string');
|
||||||
|
const valueColumn = result.columns.find(
|
||||||
|
(column) => column.type === 'number' || column.type === 'percent',
|
||||||
|
);
|
||||||
|
const labelField = labelColumn?.id ?? 'label';
|
||||||
|
const valueField = valueColumn?.id ?? 'value';
|
||||||
|
const isTrend = metric.defaultChart === 'line_area';
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: metric.defaultChart,
|
||||||
|
title: metric.name,
|
||||||
|
subtitle: metric.description,
|
||||||
|
dataset: {
|
||||||
|
source: result.rows,
|
||||||
|
label: labelField,
|
||||||
|
value: valueField,
|
||||||
|
x: isTrend ? labelField : undefined,
|
||||||
|
y: isTrend ? valueField : undefined,
|
||||||
|
},
|
||||||
|
encoding: {
|
||||||
|
x: isTrend ? { field: labelField, label: labelColumn?.label ?? labelField } : undefined,
|
||||||
|
y: isTrend ? { field: valueField, label: valueColumn?.label ?? valueField } : undefined,
|
||||||
|
value: { field: valueField, label: valueColumn?.label ?? valueField },
|
||||||
|
color: { mode: result.metricRef.metricId === 'version_risk_score' ? 'risk' : 'single' },
|
||||||
|
},
|
||||||
|
annotations: [],
|
||||||
|
stylePreset: 'apple_vision_light',
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal file
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
|
||||||
|
const top = result.rows[0];
|
||||||
|
const baseQuestion = top?.label ? `为什么${String(top.label)}最高?` : '换一个维度继续分析';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
|
||||||
|
{
|
||||||
|
type: 'drilldown',
|
||||||
|
label: '查看明细',
|
||||||
|
target: plan.dimensions[0] ?? 'analysis',
|
||||||
|
filters: plan.filters,
|
||||||
|
},
|
||||||
|
{ type: 'export', label: '导出报告', format: 'pdf' },
|
||||||
|
];
|
||||||
|
}
|
||||||
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal file
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildInsightCard(
|
||||||
|
result: MetricResult,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
semanticConfidence: InsightCard['semanticConfidence'],
|
||||||
|
): InsightCard {
|
||||||
|
const top = result.rows[0];
|
||||||
|
const topValue = top?.value ?? result.totals?.value ?? 0;
|
||||||
|
const topLabel = String(top?.label ?? metric.name);
|
||||||
|
const dataConfidence: InsightCard['dataConfidence'] =
|
||||||
|
result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
result.rows.length === 0
|
||||||
|
? `${metric.name}暂无可分析数据。`
|
||||||
|
: `${topLabel}在${metric.name}中最突出。`,
|
||||||
|
primaryValue: { label: metric.name, value: topValue },
|
||||||
|
semanticConfidence,
|
||||||
|
dataConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal file
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';
|
||||||
|
|
||||||
|
describe('metric catalog', () => {
|
||||||
|
it('defines versioned active metrics used by the MVP templates', () => {
|
||||||
|
const metric = getMetricDefinition('version_risk_score');
|
||||||
|
|
||||||
|
expect(metric).toMatchObject({
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
version: 1,
|
||||||
|
status: 'active',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose raw chart renderer contracts', () => {
|
||||||
|
const charts = listMetricDefinitions().map((item) => item.defaultChart);
|
||||||
|
|
||||||
|
expect(charts).toContain('horizontal_bar');
|
||||||
|
expect(charts).not.toContain('echarts_option');
|
||||||
|
});
|
||||||
|
});
|
||||||
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal file
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { MetricDefinition, MetricId } from '@ftb/shared';
|
||||||
|
|
||||||
|
export const METRIC_CATALOG: MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
version: 1,
|
||||||
|
name: '版本风险分',
|
||||||
|
description: '基于小宝风险摘要的当前版本风险排行。',
|
||||||
|
formula: 'xiaobao_risk_summaries.risk_score',
|
||||||
|
owner: 'xiaobao',
|
||||||
|
supportedDimensions: ['version', 'project', 'product'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'summary'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'version',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'completion_trend',
|
||||||
|
version: 1,
|
||||||
|
name: '完成趋势',
|
||||||
|
description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
|
||||||
|
formula: 'count(completed_at or terminal status updated_at) by day',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'overdue_item_count',
|
||||||
|
version: 1,
|
||||||
|
name: '逾期事项数',
|
||||||
|
description: '当前超过计划结束时间且未完成的事项数量。',
|
||||||
|
formula: 'count(open items where due_at < now)',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['version', 'project', 'member', 'department'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'version',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_status_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求状态分布',
|
||||||
|
description: '按需求状态统计需求数量。',
|
||||||
|
formula: 'count(requirements) by status',
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'requirement_status',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_completion_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求完成数量',
|
||||||
|
description: '按时间统计进入 released 或 closed 的需求数量。',
|
||||||
|
formula: "count(requirements where status in ('released','closed')) by time bucket",
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_source_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求来源/类型占比',
|
||||||
|
description: '按需求来源或类型统计需求数量。',
|
||||||
|
formula: 'count(requirements) by source_type or type',
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'distribution'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'requirement_source',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'department_workload',
|
||||||
|
version: 1,
|
||||||
|
name: '部门负载',
|
||||||
|
description: '按部门统计当前未完成事项数量。',
|
||||||
|
formula: 'count(open work items grouped by user.department_id)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['department'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'department',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'member_pending_work',
|
||||||
|
version: 1,
|
||||||
|
name: '成员待办',
|
||||||
|
description: '按成员统计当前未完成事项数量。',
|
||||||
|
formula: 'count(open work items grouped by assignee or owner)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member', 'role', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'member',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'member_effort_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '成员投入工时',
|
||||||
|
description: '按成员统计工作活动、工时记录和加班投入。',
|
||||||
|
formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member', 'department', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'comparison'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'member',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'bug_severity_count',
|
||||||
|
version: 1,
|
||||||
|
name: 'Bug 严重度分布',
|
||||||
|
description: '按严重度统计未关闭 Bug 数量。',
|
||||||
|
formula: "count(bugs where status not in ('closed','rejected')) by severity",
|
||||||
|
owner: 'quality',
|
||||||
|
supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
|
||||||
|
defaultChart: 'stacked_horizontal_bar',
|
||||||
|
defaultDimension: 'bug_severity',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'test_pass_rate',
|
||||||
|
version: 1,
|
||||||
|
name: '测试通过率',
|
||||||
|
description: '按时间统计测试通过用例占已执行用例比例。',
|
||||||
|
formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
|
||||||
|
owner: 'quality',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'overtime_reason_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '加班原因工时',
|
||||||
|
description: '按加班原因统计加班时长。',
|
||||||
|
formula: 'sum(overtime_records.hours) by reason',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'ranking'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'delay_reason',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'delay_rate',
|
||||||
|
version: 1,
|
||||||
|
name: '延期率',
|
||||||
|
description: '按范围统计逾期事项占全部计划事项比例。',
|
||||||
|
formula: 'overdue_count / planned_item_count',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['month', 'project', 'version', 'department'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'month',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'delay_reason_count',
|
||||||
|
version: 1,
|
||||||
|
name: '延期原因数量',
|
||||||
|
description: '按原因统计延期相关需求变更或加班原因。',
|
||||||
|
formula: 'count(requirement.change_reason) + count(overtime.reason)',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'delay_reason',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listMetricDefinitions(): MetricDefinition[] {
|
||||||
|
return METRIC_CATALOG.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
|
||||||
|
const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
|
||||||
|
if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
|
||||||
|
return candidates.find((item) => item.status === 'active') ?? null;
|
||||||
|
}
|
||||||
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { MetricEngine } from './metric-engine';
|
||||||
|
|
||||||
|
describe('MetricEngine', () => {
|
||||||
|
function makeEngine() {
|
||||||
|
const prisma = {
|
||||||
|
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||||
|
version: { findMany: jest.fn() },
|
||||||
|
versionPlan: { findMany: jest.fn() },
|
||||||
|
devTask: { findMany: jest.fn() },
|
||||||
|
testCase: { findMany: jest.fn() },
|
||||||
|
bug: { findMany: jest.fn() },
|
||||||
|
requirement: { findMany: jest.fn() },
|
||||||
|
taskWorklog: { findMany: jest.fn() },
|
||||||
|
overtimeRecord: { findMany: jest.fn() },
|
||||||
|
user: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
return { prisma, engine: new MetricEngine(prisma as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns version risk ranking from Xiaobao summaries', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||||
|
{ versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||||||
|
{ versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.version.findMany.mockResolvedValue([
|
||||||
|
{ id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
|
||||||
|
{ id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['version'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'project', projectId: 'project-1' },
|
||||||
|
limit: 10,
|
||||||
|
}, new Date('2026-07-08T12:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([
|
||||||
|
{ versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
|
||||||
|
{ versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
|
||||||
|
]);
|
||||||
|
expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups member pending work across plans, tasks, cases, and bugs', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
|
||||||
|
prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
|
||||||
|
prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
|
||||||
|
prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
|
||||||
|
prisma.user.findMany.mockResolvedValue([
|
||||||
|
{ id: 'm-1', name: '张三', departmentId: '研发' },
|
||||||
|
{ id: 'm-2', name: '李四', departmentId: '测试' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'member_pending_work', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['member'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'version', versionId: 'ver-1' },
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([
|
||||||
|
{ memberId: 'm-1', label: '张三', value: 3 },
|
||||||
|
{ memberId: 'm-2', label: '李四', value: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-data evidence for empty requirement trends', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.requirement.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
analysisType: 'trend',
|
||||||
|
dimensions: ['day'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
timeRange: {
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([]);
|
||||||
|
expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AnalysisPlan, DataScope, EvidenceItem, MetricResult } from '@ftb/shared';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { buildScopedWhere } from './permission-scope-resolver';
|
||||||
|
|
||||||
|
type AnalysisDomain =
|
||||||
|
| 'version'
|
||||||
|
| 'versionPlan'
|
||||||
|
| 'devTask'
|
||||||
|
| 'testCase'
|
||||||
|
| 'bug'
|
||||||
|
| 'requirement'
|
||||||
|
| 'taskWorklog'
|
||||||
|
| 'overtimeRecord';
|
||||||
|
|
||||||
|
type OpenWorkItem = {
|
||||||
|
id: string;
|
||||||
|
versionId?: string | null;
|
||||||
|
title: string;
|
||||||
|
ownerId?: string | null;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VersionRow = { id: string; name: string; projectId?: string | null; productId?: string | null };
|
||||||
|
type RiskSummaryRow = { versionId: string; riskLevel: string; riskScore: number; updatedAt: Date };
|
||||||
|
type UserRow = { id: string; name: string; departmentId?: string | null };
|
||||||
|
type UserDepartmentRow = { id: string; departmentId?: string | null };
|
||||||
|
type StatusRow = { status: string };
|
||||||
|
type RequirementSourceRow = { sourceType: string | null; type: string | null };
|
||||||
|
type DateRow = { updatedAt: Date };
|
||||||
|
type WorkHoursRow = { userId: string | null; hours: number };
|
||||||
|
type BugSeverityRow = { severity: string };
|
||||||
|
type TestStatusRow = { status: string; updatedAt: Date };
|
||||||
|
type OvertimeReasonRow = { reason: string; hours: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricEngine {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
|
||||||
|
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
|
||||||
|
|
||||||
|
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const versions = await this.prisma.version.findMany({
|
||||||
|
where: domainWhere(plan.scope, 'version'),
|
||||||
|
select: { id: true, name: true, projectId: true, productId: true },
|
||||||
|
}) as VersionRow[];
|
||||||
|
const versionById = new Map(versions.map((version) => [version.id, version]));
|
||||||
|
const versionIds = versions.map((version) => version.id);
|
||||||
|
const rows: RiskSummaryRow[] = versionIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.xiaobaoRiskSummary.findMany({
|
||||||
|
where: { versionId: { in: versionIds } },
|
||||||
|
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||||
|
take: plan.limit ?? 10,
|
||||||
|
}) as RiskSummaryRow[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '版本', type: 'string' },
|
||||||
|
{ id: 'value', label: '风险分', type: 'number' },
|
||||||
|
{ id: 'riskLevel', label: '风险等级', type: 'string' },
|
||||||
|
],
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
versionId: row.versionId,
|
||||||
|
label: versionById.get(row.versionId)?.name ?? row.versionId,
|
||||||
|
value: row.riskScore,
|
||||||
|
riskLevel: row.riskLevel,
|
||||||
|
})),
|
||||||
|
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||||||
|
this.prisma.versionPlan.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' } },
|
||||||
|
}),
|
||||||
|
this.prisma.devTask.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' } },
|
||||||
|
}),
|
||||||
|
this.prisma.testCase.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'testCase'), status: { notIn: ['passed', 'failed', 'blocked'] } },
|
||||||
|
}),
|
||||||
|
this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
|
||||||
|
}),
|
||||||
|
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
|
||||||
|
const openItems: OpenWorkItem[] = [
|
||||||
|
...plans.map((planItem) => ({ ...planItem, assigneeId: planItem.ownerId })),
|
||||||
|
...devTasks,
|
||||||
|
...testCases,
|
||||||
|
...bugs,
|
||||||
|
];
|
||||||
|
const counts = countByMember(openItems);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: Array.from(counts.keys()) } },
|
||||||
|
select: { id: true, name: true, departmentId: true },
|
||||||
|
}) as UserRow[];
|
||||||
|
const userById = new Map(users.map((user) => [user.id, user]));
|
||||||
|
const rows = Array.from(counts.entries())
|
||||||
|
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
|
||||||
|
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
|
||||||
|
.slice(0, plan.limit ?? 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '成员', type: 'string' },
|
||||||
|
{ id: 'value', label: '待办数', type: 'number' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
evidence: [{ label: '未完成事项', value: sumCounts(counts), sourceDomain: 'dev_task' }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const memberResult = await this.memberPendingWork({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'member_pending_work', version: 1 },
|
||||||
|
dimensions: ['member'],
|
||||||
|
}, now);
|
||||||
|
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: memberIds } },
|
||||||
|
select: { id: true, departmentId: true },
|
||||||
|
}) as UserDepartmentRow[];
|
||||||
|
const departmentByMember = new Map(users.map((user) => [user.id, user.departmentId || '未分部门']));
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const row of memberResult.rows) {
|
||||||
|
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
|
||||||
|
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'部门',
|
||||||
|
'待办数',
|
||||||
|
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'dev_task',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||||||
|
this.prisma.versionPlan.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' }, expectedEndAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
this.prisma.devTask.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' }, expectedEndAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
this.prisma.testCase.findMany({
|
||||||
|
where: {
|
||||||
|
...domainWhere(plan.scope, 'testCase'),
|
||||||
|
status: { notIn: ['passed', 'failed', 'blocked'] },
|
||||||
|
plannedEndAt: { lt: now },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
|
||||||
|
const byVersion = new Map<string, number>();
|
||||||
|
for (const item of [...plans, ...devTasks, ...testCases, ...bugs]) {
|
||||||
|
if (!item.versionId) continue;
|
||||||
|
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'版本',
|
||||||
|
'逾期数',
|
||||||
|
Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'version',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.requirement.findMany({
|
||||||
|
where: domainWhere(plan.scope, 'requirement'),
|
||||||
|
select: { status: true },
|
||||||
|
}) as StatusRow[];
|
||||||
|
return countRows(plan, now, rows.map((row) => row.status || 'unknown'), '状态', '数量', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const where = {
|
||||||
|
...domainWhere(plan.scope, 'requirement'),
|
||||||
|
status: { in: ['released', 'closed'] },
|
||||||
|
...timeFilter(plan, 'updatedAt'),
|
||||||
|
};
|
||||||
|
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } }) as DateRow[];
|
||||||
|
return trendRows(plan, now, rows.map((row) => row.updatedAt), '完成需求', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const where = { ...domainWhere(plan.scope, 'requirement'), ...timeFilter(plan, 'createdAt') };
|
||||||
|
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } }) as RequirementSourceRow[];
|
||||||
|
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
|
||||||
|
return countRows(plan, now, rows.map((row) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [worklogs, overtime] = await Promise.all([
|
||||||
|
this.prisma.taskWorklog.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'taskWorklog'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { userId: true, hours: true },
|
||||||
|
}),
|
||||||
|
this.prisma.overtimeRecord.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { userId: true, hours: true },
|
||||||
|
}),
|
||||||
|
]) as [WorkHoursRow[], WorkHoursRow[]];
|
||||||
|
const hours = new Map<string, number>();
|
||||||
|
for (const row of [...worklogs, ...overtime]) {
|
||||||
|
if (!row.userId) continue;
|
||||||
|
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
|
||||||
|
}
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: Array.from(hours.keys()) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}) as Array<Pick<UserRow, 'id' | 'name'>>;
|
||||||
|
const userById = new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'成员',
|
||||||
|
'小时',
|
||||||
|
Array.from(hours.entries()).map(([memberId, value]) => ({
|
||||||
|
memberId,
|
||||||
|
label: userById.get(memberId) ?? memberId,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
'task_worklog',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { notIn: ['closed', 'rejected'] } },
|
||||||
|
select: { severity: true },
|
||||||
|
}) as BugSeverityRow[];
|
||||||
|
return countRows(plan, now, rows.map((row) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.testCase.findMany({
|
||||||
|
where: {
|
||||||
|
...domainWhere(plan.scope, 'testCase'),
|
||||||
|
status: { in: ['passed', 'failed', 'blocked'] },
|
||||||
|
...timeFilter(plan, 'updatedAt'),
|
||||||
|
},
|
||||||
|
select: { status: true, updatedAt: true },
|
||||||
|
}) as TestStatusRow[];
|
||||||
|
const buckets = bucketDates(rows.map((row) => row.updatedAt));
|
||||||
|
const source = Array.from(buckets.keys()).map((label) => {
|
||||||
|
const sameDay = rows.filter((row) => dayKey(row.updatedAt) === label);
|
||||||
|
const passed = sameDay.filter((row) => row.status === 'passed').length;
|
||||||
|
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
|
||||||
|
});
|
||||||
|
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.overtimeRecord.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { reason: true, hours: true },
|
||||||
|
}) as OvertimeReasonRow[];
|
||||||
|
const hours = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const reason = row.reason || '未填写';
|
||||||
|
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'原因',
|
||||||
|
'小时',
|
||||||
|
Array.from(hours.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'overtime',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
return this.requirementCompletionTrend({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
dimensions: ['day'],
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const overdue = await this.overdueItemCount({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'overdue_item_count', version: 1 },
|
||||||
|
dimensions: ['version'],
|
||||||
|
analysisType: 'ranking',
|
||||||
|
}, now);
|
||||||
|
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
|
||||||
|
return {
|
||||||
|
...overdue,
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
return this.overtimeReasonHours({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'overtime_reason_hours', version: 1 },
|
||||||
|
dimensions: ['delay_reason'],
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainWhere(scope: DataScope, domain: AnalysisDomain): Record<string, unknown> {
|
||||||
|
const scopedWhere = buildScopedWhere(scope);
|
||||||
|
const base = domain === 'version' && 'versionId' in scopedWhere
|
||||||
|
? { id: scopedWhere.versionId }
|
||||||
|
: scopedWhere;
|
||||||
|
|
||||||
|
if (scope.type !== 'self') return base;
|
||||||
|
|
||||||
|
if (domain === 'version') return { id: { in: [] } };
|
||||||
|
if (domain === 'versionPlan') return { ...base, ownerId: scope.userId };
|
||||||
|
if (domain === 'devTask' || domain === 'testCase' || domain === 'bug') return { ...base, assigneeId: scope.userId };
|
||||||
|
if (domain === 'requirement') return { ...base, creatorId: scope.userId };
|
||||||
|
if (domain === 'taskWorklog' || domain === 'overtimeRecord') return { ...base, userId: scope.userId };
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeFilter(plan: AnalysisPlan, field: string): Record<string, unknown> {
|
||||||
|
if (!plan.timeRange) return {};
|
||||||
|
return { [field]: { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByMember(items: OpenWorkItem[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
const memberId = item.assigneeId ?? item.ownerId;
|
||||||
|
if (!memberId) continue;
|
||||||
|
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounts(counts: Map<string, number>): number {
|
||||||
|
return Array.from(counts.values()).reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsResult(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
labelName: string,
|
||||||
|
valueName: string,
|
||||||
|
rows: Array<Record<string, string | number | null>>,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const sorted = rows
|
||||||
|
.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0))
|
||||||
|
.slice(0, plan.limit ?? rows.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: labelName, type: 'string' },
|
||||||
|
{ id: 'value', label: valueName, type: 'number' },
|
||||||
|
],
|
||||||
|
rows: sorted,
|
||||||
|
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRows(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
labels: string[],
|
||||||
|
labelName: string,
|
||||||
|
valueName: string,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
labelName,
|
||||||
|
valueName,
|
||||||
|
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
sourceDomain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendRows(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
dates: Date[],
|
||||||
|
valueLabel: string,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const buckets = bucketDates(dates);
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'日期',
|
||||||
|
valueLabel,
|
||||||
|
Array.from(buckets.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
sourceDomain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketDates(dates: Date[]): Map<string, number> {
|
||||||
|
const buckets = new Map<string, number>();
|
||||||
|
for (const date of dates) {
|
||||||
|
const key = dayKey(date);
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(value: Date | string): string {
|
||||||
|
return new Date(value).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
evidence,
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { buildScopedWhere, PermissionScopeResolver } from './permission-scope-resolver';
|
||||||
|
|
||||||
|
describe('PermissionScopeResolver', () => {
|
||||||
|
function makeResolver() {
|
||||||
|
const prisma = {
|
||||||
|
project: { findFirst: jest.fn() },
|
||||||
|
version: { findFirst: jest.fn() },
|
||||||
|
projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
};
|
||||||
|
const rbac = {
|
||||||
|
assertGlobalPermission: jest.fn(),
|
||||||
|
assertProjectRole: jest.fn(),
|
||||||
|
};
|
||||||
|
return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns system scope for wildcard permissions', async () => {
|
||||||
|
const { rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-8',
|
||||||
|
permissions: ['*'],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).resolves.toEqual({ type: 'system', reason: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns managed project scope for management permission', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-pm',
|
||||||
|
permissions: ['management:view'],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restricts version context to the requested version when the user has page access', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
|
||||||
|
rbac.assertProjectRole.mockResolvedValue({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
projectId: 'project-1',
|
||||||
|
role: 'member',
|
||||||
|
via: 'project_member',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'version_detail', versionId: 'version-1' },
|
||||||
|
})).resolves.toEqual({ type: 'version', versionId: 'version-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows product context to project memberships when the actor lacks global product access', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockRejectedValue(new ForbiddenException('No product permission'));
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([
|
||||||
|
{ projectId: 'project-1' },
|
||||||
|
{ projectId: 'project-1' },
|
||||||
|
{ projectId: 'project-2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'product_detail', productId: 'product-1' },
|
||||||
|
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing actor id', async () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: '',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds relation-table where filters for concrete scopes', () => {
|
||||||
|
expect(buildScopedWhere({ type: 'product', productId: 'product-1' })).toEqual({ productId: 'product-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'project', projectId: 'project-1' })).toEqual({ projectId: 'project-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'version', versionId: 'version-1' })).toEqual({ versionId: 'version-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] })).toEqual({
|
||||||
|
projectId: { in: ['project-1', 'project-2'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { AnalysisRequest, DataScope } from '@ftb/shared';
|
||||||
|
import { RbacService } from '../../../common/rbac/rbac.service';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface AnalysisScopeInput {
|
||||||
|
actorId?: string;
|
||||||
|
permissions: string[];
|
||||||
|
context?: AnalysisRequest['context'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScopedWhere = {
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string | { in: string[] };
|
||||||
|
versionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionScopeResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly rbac: RbacService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
|
||||||
|
const actorId = input.actorId?.trim();
|
||||||
|
if (!actorId) throw new ForbiddenException('Missing actor scope');
|
||||||
|
|
||||||
|
const permissions = input.permissions ?? [];
|
||||||
|
const context = input.context;
|
||||||
|
|
||||||
|
if (context?.surface === 'version_detail' && context.versionId) {
|
||||||
|
return this.resolveVersionScope(actorId, permissions, context.versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.surface === 'project_detail' && context.projectId) {
|
||||||
|
await this.rbac.assertProjectRole({
|
||||||
|
actorId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
allowedRoles: ['viewer'],
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
return { type: 'project', projectId: context.projectId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.surface === 'product_detail' && context.productId) {
|
||||||
|
return this.resolveProductScope(actorId, permissions, context.productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('*')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'system', reason: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('management:view')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return this.resolveManagedProjectsScope(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedScope = await this.resolveManagedProjectsScope(actorId);
|
||||||
|
if (managedScope.projectIds.length > 0) return managedScope;
|
||||||
|
|
||||||
|
return { type: 'self', userId: actorId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveVersionScope(
|
||||||
|
actorId: string,
|
||||||
|
permissions: string[],
|
||||||
|
versionId: string,
|
||||||
|
): Promise<DataScope> {
|
||||||
|
const version = await this.prisma.version.findFirst({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: { id: true, projectId: true },
|
||||||
|
});
|
||||||
|
if (!version) throw new NotFoundException('Version not found');
|
||||||
|
|
||||||
|
if (!version.projectId) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['version:view', 'management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'version', versionId: version.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rbac.assertProjectRole({
|
||||||
|
actorId,
|
||||||
|
projectId: version.projectId,
|
||||||
|
allowedRoles: ['viewer'],
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
return { type: 'version', versionId: version.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProductScope(
|
||||||
|
actorId: string,
|
||||||
|
permissions: string[],
|
||||||
|
productId: string,
|
||||||
|
): Promise<DataScope> {
|
||||||
|
if (permissions.includes('*')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'product', productId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('management:view') || permissions.includes('product:view')) {
|
||||||
|
try {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view', 'product:view'],
|
||||||
|
});
|
||||||
|
return { type: 'product', productId };
|
||||||
|
} catch {
|
||||||
|
// Fall through to project membership narrowing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.projectMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actorId,
|
||||||
|
project: { productId },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
const projectIds = uniqueProjectIds(rows);
|
||||||
|
if (projectIds.length === 0) throw new ForbiddenException('No product analysis scope');
|
||||||
|
|
||||||
|
return { type: 'managed_projects', projectIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveManagedProjectsScope(actorId: string): Promise<Extract<DataScope, { type: 'managed_projects' }>> {
|
||||||
|
const rows = await this.prisma.projectMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actorId,
|
||||||
|
role: { in: ['owner', 'admin'] },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
return { type: 'managed_projects', projectIds: uniqueProjectIds(rows) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScopedWhere(scope: DataScope): ScopedWhere {
|
||||||
|
if (scope.type === 'product') return { productId: scope.productId };
|
||||||
|
if (scope.type === 'project') return { projectId: scope.projectId };
|
||||||
|
if (scope.type === 'version') return { versionId: scope.versionId };
|
||||||
|
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueProjectIds(rows: Array<{ projectId: string | null | undefined }>): string[] {
|
||||||
|
return Array.from(new Set(rows.map((row) => row.projectId).filter((projectId): projectId is string => Boolean(projectId))));
|
||||||
|
}
|
||||||
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { AnalysisReportBuilder } from './report-builder';
|
||||||
|
import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
describe('AnalysisReportBuilder', () => {
|
||||||
|
it('describes normalized time ranges from the analysis plan', async () => {
|
||||||
|
const builder = new AnalysisReportBuilder();
|
||||||
|
const metric = {
|
||||||
|
metricId: 'member_effort_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '成员投入工时',
|
||||||
|
description: '成员投入',
|
||||||
|
formula: 'sum(hours)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member'],
|
||||||
|
supportedAnalysisTypes: ['ranking'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
} as MetricDefinition;
|
||||||
|
const result = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '成员', type: 'string' },
|
||||||
|
{ id: 'value', label: '小时', type: 'number' },
|
||||||
|
],
|
||||||
|
rows: [{ label: '张三', value: 8 }],
|
||||||
|
evidence: [],
|
||||||
|
dataScope: { type: 'self', userId: 'm-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
} as MetricResult;
|
||||||
|
const insight = {
|
||||||
|
summary: '张三投入最高。',
|
||||||
|
semanticConfidence: 'high',
|
||||||
|
dataConfidence: 'partial',
|
||||||
|
} as InsightCard;
|
||||||
|
const plan = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['member'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
timeRange: {
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
},
|
||||||
|
} as AnalysisPlan;
|
||||||
|
|
||||||
|
const report = await (builder.build as any)(result, insight, metric, plan);
|
||||||
|
|
||||||
|
expect(report.dataScope.timeDescription).toBe('最近 30 天');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
AnalysisPlan,
|
||||||
|
AnalysisReport,
|
||||||
|
DataScope,
|
||||||
|
InsightCard,
|
||||||
|
MetricDefinition,
|
||||||
|
MetricResult,
|
||||||
|
} from '@ftb/shared';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnalysisReportBuilder {
|
||||||
|
async build(
|
||||||
|
result: MetricResult,
|
||||||
|
insight: InsightCard,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
plan?: AnalysisPlan,
|
||||||
|
): Promise<AnalysisReport> {
|
||||||
|
return {
|
||||||
|
summary: insight.summary,
|
||||||
|
keyFindings: result.rows
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((row) => `${String(row.label ?? '对象')}:${String(row.value ?? 0)}`),
|
||||||
|
evidence: result.evidence,
|
||||||
|
suggestions:
|
||||||
|
result.rows.length > 0
|
||||||
|
? ['优先查看排名靠前的对象,并进入明细确认原因。']
|
||||||
|
: ['调整时间范围或切换分析维度。'],
|
||||||
|
dataScope: {
|
||||||
|
timeDescription: describeTimeScope(plan, result),
|
||||||
|
permissionDescription: describeScope(result.dataScope),
|
||||||
|
metricFormulaDescription: metric.formula,
|
||||||
|
generatedAt: result.generatedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultHasTime(result: MetricResult): boolean {
|
||||||
|
return result.columns.some((column) => column.type === 'date');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTimeScope(plan: AnalysisPlan | undefined, result: MetricResult): string {
|
||||||
|
if (!plan?.timeRange) {
|
||||||
|
return resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.timeRange.policy === 'current_state') return '当前状态';
|
||||||
|
if (plan.timeRange.policy === 'last_30_days') return '最近 30 天';
|
||||||
|
if (plan.timeRange.policy === 'lifecycle') return '对象生命周期';
|
||||||
|
if (plan.timeRange.policy === 'user_required') return '用户指定时间范围';
|
||||||
|
return `${formatDate(plan.timeRange.start)} 至 ${formatDate(plan.timeRange.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return value.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeScope(scope: DataScope): string {
|
||||||
|
if (scope.type === 'system') return '系统管理范围';
|
||||||
|
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
|
||||||
|
if (scope.type === 'self') return '与当前用户相关的数据';
|
||||||
|
if (scope.type === 'product') return `产品范围:${scope.productId}`;
|
||||||
|
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
|
||||||
|
return `版本范围:${scope.versionId}`;
|
||||||
|
}
|
||||||
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal file
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class AnalysisContextDto {
|
||||||
|
@IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
|
||||||
|
surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalysisDto {
|
||||||
|
@IsString()
|
||||||
|
question!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => AnalysisContextDto)
|
||||||
|
context?: AnalysisContextDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal file
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
|
||||||
|
你是 FTB 项目管理系统的业务分析计划助手。
|
||||||
|
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL,不能绕过权限。
|
||||||
|
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
|
||||||
|
当问题无法映射到已暴露能力时,返回 clarificationOptions,不要编造指标。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_TOOL_DESCRIPTION =
|
||||||
|
'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['metricId', 'analysisType', 'dimensions', 'filters'],
|
||||||
|
properties: {
|
||||||
|
metricId: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'version_risk_score',
|
||||||
|
'completion_trend',
|
||||||
|
'overdue_item_count',
|
||||||
|
'requirement_status_count',
|
||||||
|
'requirement_completion_count',
|
||||||
|
'requirement_source_count',
|
||||||
|
'department_workload',
|
||||||
|
'member_pending_work',
|
||||||
|
'member_effort_hours',
|
||||||
|
'bug_severity_count',
|
||||||
|
'test_pass_rate',
|
||||||
|
'overtime_reason_hours',
|
||||||
|
'delay_rate',
|
||||||
|
'delay_reason_count',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metricVersion: { type: 'number' },
|
||||||
|
analysisType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'ranking',
|
||||||
|
'trend',
|
||||||
|
'comparison',
|
||||||
|
'distribution',
|
||||||
|
'composition',
|
||||||
|
'correlation',
|
||||||
|
'breakdown',
|
||||||
|
'summary',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'product',
|
||||||
|
'project',
|
||||||
|
'version',
|
||||||
|
'requirement_status',
|
||||||
|
'requirement_type',
|
||||||
|
'requirement_source',
|
||||||
|
'department',
|
||||||
|
'member',
|
||||||
|
'role',
|
||||||
|
'month',
|
||||||
|
'week',
|
||||||
|
'day',
|
||||||
|
'bug_severity',
|
||||||
|
'bug_status',
|
||||||
|
'test_status',
|
||||||
|
'delay_reason',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filters: { type: 'object', additionalProperties: true },
|
||||||
|
timeRange: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['start', 'end'],
|
||||||
|
properties: {
|
||||||
|
start: { type: 'string' },
|
||||||
|
end: { type: 'string' },
|
||||||
|
policy: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: { type: 'number', minimum: 1, maximum: 20 },
|
||||||
|
sort: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['field', 'direction'],
|
||||||
|
properties: {
|
||||||
|
field: { type: 'string' },
|
||||||
|
direction: { type: 'string', enum: ['asc', 'desc'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal file
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
|
||||||
|
你是 FTB 项目管理系统的业务分析报告助手。
|
||||||
|
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
|
||||||
|
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
|
||||||
|
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_TOOL_DESCRIPTION =
|
||||||
|
'Write a structured business analysis report from deterministic metric results without inventing facts.';
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['summary', 'keyFindings', 'suggestions'],
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
keyFindings: { type: 'array', items: { type: 'string' } },
|
||||||
|
suggestions: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -103,4 +103,48 @@ describe('AnthropicProvider', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||||
|
mockCreate.mockResolvedValue({
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_test',
|
||||||
|
name: 'submit_decompose',
|
||||||
|
input: { ok: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 34,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = new AnthropicProvider('test-key', 'https://example.test');
|
||||||
|
|
||||||
|
await provider.callTool({
|
||||||
|
systemPrompt: '只通过工具返回',
|
||||||
|
userPrompt: '拆解这个原型',
|
||||||
|
tool: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
description: '提交拆解结果',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { ok: { type: 'boolean' } },
|
||||||
|
required: ['ok'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 1000,
|
||||||
|
model: 'claude-test',
|
||||||
|
timeoutMs: 110000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ timeout: 110000, maxRetries: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class AnthropicProvider implements AiProvider {
|
|||||||
} as any)
|
} as any)
|
||||||
: { type: 'auto' as const };
|
: { type: 'auto' as const };
|
||||||
|
|
||||||
const response = await this.client.messages.create({
|
const body = {
|
||||||
model: req.model,
|
model: req.model,
|
||||||
max_tokens: req.maxTokens,
|
max_tokens: req.maxTokens,
|
||||||
system: req.systemPrompt,
|
system: req.systemPrompt,
|
||||||
@@ -36,8 +36,11 @@ export class AnthropicProvider implements AiProvider {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_choice: toolChoice,
|
tool_choice: toolChoice,
|
||||||
messages: [{ role: 'user', content: req.userPrompt }],
|
messages: [{ role: 'user' as const, content: req.userPrompt }],
|
||||||
});
|
};
|
||||||
|
const response = req.timeoutMs
|
||||||
|
? await this.client.messages.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||||
|
: await this.client.messages.create(body);
|
||||||
|
|
||||||
const toolUse = response.content.find(
|
const toolUse = response.content.find(
|
||||||
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
|
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
|
||||||
|
|||||||
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { OpenAIProvider } from './openai.provider';
|
||||||
|
|
||||||
|
const mockCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('openai', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(() => ({
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: mockCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OpenAIProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||||
|
mockCreate.mockResolvedValue({
|
||||||
|
model: 'openai-test',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
arguments: JSON.stringify({ ok: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 12,
|
||||||
|
completion_tokens: 34,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = new OpenAIProvider('test-key', 'https://example.test/v1');
|
||||||
|
|
||||||
|
await provider.callTool({
|
||||||
|
systemPrompt: '只通过工具返回',
|
||||||
|
userPrompt: '拆解这个原型',
|
||||||
|
tool: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
description: '提交拆解结果',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { ok: { type: 'boolean' } },
|
||||||
|
required: ['ok'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 1000,
|
||||||
|
model: 'openai-test',
|
||||||
|
timeoutMs: 110000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ timeout: 110000, maxRetries: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,16 +16,16 @@ export class OpenAIProvider implements AiProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
|
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
|
||||||
const response = await this.client.chat.completions.create({
|
const body = {
|
||||||
model: req.model,
|
model: req.model,
|
||||||
max_tokens: req.maxTokens,
|
max_tokens: req.maxTokens,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: req.systemPrompt },
|
{ role: 'system' as const, content: req.systemPrompt },
|
||||||
{ role: 'user', content: req.userPrompt },
|
{ role: 'user' as const, content: req.userPrompt },
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
name: req.tool.name,
|
name: req.tool.name,
|
||||||
description: req.tool.description,
|
description: req.tool.description,
|
||||||
@@ -34,9 +34,12 @@ export class OpenAIProvider implements AiProvider {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_choice: req.forceTool
|
tool_choice: req.forceTool
|
||||||
? { type: 'function', function: { name: req.tool.name } }
|
? { type: 'function' as const, function: { name: req.tool.name } }
|
||||||
: 'auto',
|
: 'auto' as const,
|
||||||
});
|
};
|
||||||
|
const response = req.timeoutMs
|
||||||
|
? await this.client.chat.completions.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||||
|
: await this.client.chat.completions.create(body);
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
if (!choice?.message?.tool_calls?.length) {
|
if (!choice?.message?.tool_calls?.length) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ToolCallRequest {
|
|||||||
forceTool: boolean;
|
forceTool: boolean;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
model: string;
|
model: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallResponse {
|
export interface ToolCallResponse {
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ describe('AppDataRetirementService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps members AppData writable for legacy department role and password-rule config', () => {
|
||||||
|
expect(APP_DATA_RETIREMENT_CONFIG.members.state).toBe('active');
|
||||||
|
expect(() => service.assertWritable('members')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps overtime AppData writable for legacy overtime reason config', () => {
|
||||||
|
expect(APP_DATA_RETIREMENT_CONFIG.overtime.state).toBe('active');
|
||||||
|
expect(() => service.assertWritable('overtime')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('treats archived documents as read-only', () => {
|
it('treats archived documents as read-only', () => {
|
||||||
try {
|
try {
|
||||||
service.assertWritable('xiaobao-risk-snapshots');
|
service.assertWritable('xiaobao-risk-snapshots');
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
|
|||||||
replacement: '/api/v1/versions/:versionId/bugs',
|
replacement: '/api/v1/versions/:versionId/bugs',
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
state: 'write_frozen',
|
state: 'active',
|
||||||
replacement: '/api/v1/members',
|
replacement: '/api/v1/members for member identities; legacy members AppData remains temporary storage for departments, roles, and password policy.',
|
||||||
note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。',
|
note: '成员身份已迁移到领域 API;部门、角色和密码策略在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||||
},
|
},
|
||||||
'task-categories': {
|
'task-categories': {
|
||||||
state: 'write_frozen',
|
state: 'write_frozen',
|
||||||
@@ -67,9 +67,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
|
|||||||
note: '个人已读状态等待企业协作/通知治理阶段承接。',
|
note: '个人已读状态等待企业协作/通知治理阶段承接。',
|
||||||
},
|
},
|
||||||
overtime: {
|
overtime: {
|
||||||
state: 'write_frozen',
|
state: 'active',
|
||||||
replacement: '/api/v1/overtime',
|
replacement: '/api/v1/overtime for overtime records; legacy overtime AppData remains temporary storage for overtime reason config.',
|
||||||
note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。',
|
note: '加班记录已迁移到领域 API;加班原因配置在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||||
},
|
},
|
||||||
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
|
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ describe('BugService domain writes', () => {
|
|||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -26,6 +29,61 @@ describe('BugService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves assignee and reporter names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 开发: 'dev-1', 测试: 'qa-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: '开发',
|
||||||
|
reportedBy: '测试',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.bug.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves transfer assignee names before updating bug foreign keys', async () => {
|
||||||
|
const { prisma, workActivity, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||||
|
prisma.bug.findFirst.mockResolvedValue({
|
||||||
|
id: 'bug-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'open',
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
});
|
||||||
|
prisma.bug.update.mockResolvedValue({
|
||||||
|
id: 'bug-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: 'dev-2',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.transfer('version-1', 'bug-1', '李四', 'qa-1');
|
||||||
|
|
||||||
|
expect(prisma.bug.update).toHaveBeenCalledWith({
|
||||||
|
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
|
||||||
|
data: { assigneeId: 'dev-2' },
|
||||||
|
});
|
||||||
|
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('creates bugs directly under a version partition', async () => {
|
it('creates bugs directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -151,3 +209,13 @@ describe('BugService domain writes', () => {
|
|||||||
expect(prisma.bug.update).not.toHaveBeenCalled();
|
expect(prisma.bug.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateBugDto } from './dto/create-bug.dto';
|
import { CreateBugDto } from './dto/create-bug.dto';
|
||||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||||
@@ -14,9 +15,10 @@ export class BugService {
|
|||||||
|
|
||||||
async create(versionId: string, dto: CreateBugDto) {
|
async create(versionId: string, dto: CreateBugDto) {
|
||||||
const version = await this.ensureVersion(versionId);
|
const version = await this.ensureVersion(versionId);
|
||||||
|
const bugData = await this.toBugData(dto);
|
||||||
const item = await this.prisma.bug.create({
|
const item = await this.prisma.bug.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toBugData(dto),
|
...bugData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -43,7 +45,7 @@ export class BugService {
|
|||||||
await this.ensureBugInVersion(versionId, id);
|
await this.ensureBugInVersion(versionId, id);
|
||||||
const item = await this.prisma.bug.update({
|
const item = await this.prisma.bug.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toBugData(dto),
|
data: await this.toBugData(dto),
|
||||||
});
|
});
|
||||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
@@ -70,16 +72,17 @@ export class BugService {
|
|||||||
|
|
||||||
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
|
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
|
||||||
const current = await this.ensureBugInVersion(versionId, id);
|
const current = await this.ensureBugInVersion(versionId, id);
|
||||||
|
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||||
const item = await this.prisma.bug.update({
|
const item = await this.prisma.bug.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: { assigneeId },
|
data: { assigneeId: resolvedAssigneeId },
|
||||||
});
|
});
|
||||||
const activity = await this.recordBugActivity(
|
const activity = await this.recordBugActivity(
|
||||||
item,
|
item,
|
||||||
'bug_transferred',
|
'bug_transferred',
|
||||||
'progress',
|
'progress',
|
||||||
`转交 Bug:${item.title} → ${assigneeId}`,
|
`转交 Bug:${item.title} → ${resolvedAssigneeId ?? '-'}`,
|
||||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator },
|
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId, operator },
|
||||||
);
|
);
|
||||||
return { item, activities: [activity] };
|
return { item, activities: [activity] };
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ export class BugService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toBugData(dto: Partial<CreateBugDto>) {
|
private async toBugData(dto: Partial<CreateBugDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
|
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
|
||||||
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
|
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
|
||||||
@@ -101,8 +104,10 @@ export class BugService {
|
|||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
|
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
|
||||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||||
...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}),
|
...(dto.reporterId !== undefined || dto.reportedBy !== undefined
|
||||||
|
? { reporterId: await resolveUserReference(this.prisma, dto.reporterId ?? dto.reportedBy) }
|
||||||
|
: {}),
|
||||||
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
|
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
|
||||||
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
|
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
|
||||||
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
|
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
describe('DataService', () => {
|
describe('DataService', () => {
|
||||||
@@ -14,9 +15,7 @@ describe('DataService', () => {
|
|||||||
const syncService = {
|
const syncService = {
|
||||||
syncAfterAppDataPut: jest.fn(),
|
syncAfterAppDataPut: jest.fn(),
|
||||||
};
|
};
|
||||||
const retirementService = {
|
const retirementService = new AppDataRetirementService();
|
||||||
assertWritable: jest.fn(),
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
syncService,
|
syncService,
|
||||||
@@ -58,36 +57,29 @@ describe('DataService', () => {
|
|||||||
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts JSON values for allowed keys', async () => {
|
it('upserts JSON values for active config keys', async () => {
|
||||||
const { prisma, service, syncService, retirementService } = makeService();
|
const { prisma, service, syncService, retirementService } = makeService();
|
||||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
const writableSpy = jest.spyOn(retirementService, 'assertWritable');
|
||||||
|
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
|
|
||||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
await expect(service.put('members', value)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||||
where: { key: 'products-overview' },
|
where: { key: 'members' },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { key: 'products-overview', value },
|
create: { key: 'members', value },
|
||||||
});
|
});
|
||||||
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
|
expect(writableSpy).toHaveBeenCalledWith('members');
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
|
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
|
||||||
const { prisma, service, syncService, retirementService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
retirementService.assertWritable.mockImplementation(() => {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'APP_DATA_WRITE_FROZEN',
|
|
||||||
key: 'dev-tasks',
|
|
||||||
state: 'write_frozen',
|
|
||||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
|
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
|
||||||
response: expect.objectContaining({
|
response: expect.objectContaining({
|
||||||
@@ -102,59 +94,62 @@ describe('DataService', () => {
|
|||||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows supporting business data keys migrated from browser storage', async () => {
|
it('allows only active config AppData keys after business key retirement', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value: unknown[] = [];
|
const membersValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
|
const overtimeValue = { records: [], reasons: [] };
|
||||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||||
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
|
Promise.resolve({
|
||||||
|
key: where.key,
|
||||||
|
value: where.key === 'members' ? membersValue : overtimeValue,
|
||||||
|
updatedAt: new Date('2026-07-02T08:02:00.000Z'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
|
await expect(service.put('members', membersValue)).resolves.toMatchObject({ key: 'members', value: membersValue });
|
||||||
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
|
await expect(service.put('overtime', overtimeValue)).resolves.toMatchObject({ key: 'overtime', value: overtimeValue });
|
||||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
|
await expect(service.put('task-worklogs', [])).rejects.toBeInstanceOf(ConflictException);
|
||||||
key: 'overtime',
|
await expect(service.put('work-activities', [])).rejects.toBeInstanceOf(ConflictException);
|
||||||
value,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates only when the supplied version matches the stored row version', async () => {
|
it('updates only when the supplied version matches the stored row version', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const previousVersion = '2026-07-02T08:03:00.000Z';
|
const previousVersion = '2026-07-02T08:03:00.000Z';
|
||||||
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
||||||
const nextValue = [{ id: 'p2', name: 'Product 2' }];
|
const nextValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
updatedAt: nextUpdatedAt,
|
updatedAt: nextUpdatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
|
await expect(service.put('members', nextValue, previousVersion)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
version: nextUpdatedAt.toISOString(),
|
version: nextUpdatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
updatedAt: new Date(previousVersion),
|
updatedAt: new Date(previousVersion),
|
||||||
},
|
},
|
||||||
data: { value: nextValue },
|
data: { value: nextValue },
|
||||||
});
|
});
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects stale AppData versions without overwriting the current value', async () => {
|
it('rejects stale AppData versions without overwriting the current value', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: [{ id: 'current' }],
|
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||||
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
|
service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, '2026-07-02T08:03:00.000Z'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||||
@@ -162,32 +157,32 @@ describe('DataService', () => {
|
|||||||
|
|
||||||
it('creates a missing row only when the client loaded a null version', async () => {
|
it('creates a missing row only when the client loaded a null version', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const value = [{ id: 'p1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
||||||
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.create.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
|
|
||||||
await expect(service.put('products-overview', value, null)).resolves.toEqual({
|
await expect(service.put('members', value, null)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.create).toHaveBeenCalledWith({
|
expect(prisma.appData.create).toHaveBeenCalledWith({
|
||||||
data: { key: 'products-overview', value },
|
data: { key: 'members', value },
|
||||||
});
|
});
|
||||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create-only writes when another client created the row first', async () => {
|
it('rejects create-only writes when another client created the row first', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: [{ id: 'current' }],
|
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||||
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
|
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, null)).rejects.toBeInstanceOf(
|
||||||
ConflictException,
|
ConflictException,
|
||||||
);
|
);
|
||||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||||
@@ -196,24 +191,24 @@ describe('DataService', () => {
|
|||||||
it('rejects invalid AppData versions', async () => {
|
it('rejects invalid AppData versions', async () => {
|
||||||
const { service } = makeService();
|
const { service } = makeService();
|
||||||
|
|
||||||
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
|
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, 'not-a-date')).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the AppData response successful when relation sync fails', async () => {
|
it('keeps the AppData response successful when relation sync fails', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const value = [{ id: 'p1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
|
||||||
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
|
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
|
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
|
||||||
|
|
||||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
await expect(service.put('members', value)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('members'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ describe('DevTaskService domain writes', () => {
|
|||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -26,6 +29,58 @@ describe('DevTaskService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves assignee and creator names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 张三: 'dev-1', 产品经理: 'pm-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: '张三',
|
||||||
|
createdBy: '产品经理',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.devTask.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
creatorId: 'pm-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves transfer assignee names before updating foreign keys', async () => {
|
||||||
|
const { prisma, workActivity, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||||
|
prisma.devTask.findFirst.mockResolvedValue({
|
||||||
|
id: 'task-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
});
|
||||||
|
prisma.devTask.update.mockResolvedValue({
|
||||||
|
id: 'task-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: 'dev-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.transfer('version-1', 'task-1', '李四');
|
||||||
|
|
||||||
|
expect(prisma.devTask.update).toHaveBeenCalledWith({
|
||||||
|
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
|
||||||
|
data: { assigneeId: 'dev-2' },
|
||||||
|
});
|
||||||
|
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('creates dev tasks directly under a version partition', async () => {
|
it('creates dev tasks directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -151,3 +206,13 @@ describe('DevTaskService domain writes', () => {
|
|||||||
expect(prisma.devTask.update).not.toHaveBeenCalled();
|
expect(prisma.devTask.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||||
@@ -17,9 +18,10 @@ export class DevTaskService {
|
|||||||
if (!version.projectId) {
|
if (!version.projectId) {
|
||||||
throw new BadRequestException('开发任务所属版本必须归属于项目');
|
throw new BadRequestException('开发任务所属版本必须归属于项目');
|
||||||
}
|
}
|
||||||
|
const taskData = await this.toTaskData(dto);
|
||||||
const item = await this.prisma.devTask.create({
|
const item = await this.prisma.devTask.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toTaskData(dto),
|
...taskData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -44,7 +46,7 @@ export class DevTaskService {
|
|||||||
await this.ensureTaskInVersion(versionId, id);
|
await this.ensureTaskInVersion(versionId, id);
|
||||||
const item = await this.prisma.devTask.update({
|
const item = await this.prisma.devTask.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTaskData(dto),
|
data: await this.toTaskData(dto),
|
||||||
});
|
});
|
||||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
@@ -84,16 +86,17 @@ export class DevTaskService {
|
|||||||
|
|
||||||
async transfer(versionId: string, id: string, assigneeId: string) {
|
async transfer(versionId: string, id: string, assigneeId: string) {
|
||||||
const current = await this.ensureTaskInVersion(versionId, id);
|
const current = await this.ensureTaskInVersion(versionId, id);
|
||||||
|
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||||
const item = await this.prisma.devTask.update({
|
const item = await this.prisma.devTask.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: { assigneeId },
|
data: { assigneeId: resolvedAssigneeId },
|
||||||
});
|
});
|
||||||
const activity = await this.recordTaskActivity(
|
const activity = await this.recordTaskActivity(
|
||||||
item,
|
item,
|
||||||
'dev_task_transferred',
|
'dev_task_transferred',
|
||||||
'progress',
|
'progress',
|
||||||
`转派开发任务:${item.title}`,
|
`转派开发任务:${item.title}`,
|
||||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId },
|
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId },
|
||||||
);
|
);
|
||||||
return { item, activities: [activity] };
|
return { item, activities: [activity] };
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ export class DevTaskService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
private async toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
||||||
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
||||||
@@ -115,8 +118,10 @@ export class DevTaskService {
|
|||||||
...(dto.description !== undefined && { description: dto.description ?? '' }),
|
...(dto.description !== undefined && { description: dto.description ?? '' }),
|
||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
|
...(dto.creatorId !== undefined || dto.createdBy !== undefined
|
||||||
|
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
|
||||||
|
: {}),
|
||||||
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
|
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
|
||||||
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
|
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
|
||||||
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
|
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ describe('GovernanceService', () => {
|
|||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
},
|
},
|
||||||
|
appData: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const audit = { record: jest.fn() } as unknown as AuditService;
|
const audit = { record: jest.fn() } as unknown as AuditService;
|
||||||
const rbac = {
|
const rbac = {
|
||||||
@@ -80,6 +83,56 @@ describe('GovernanceService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not backfill requirement dictionaries from legacy requirement AppData during runtime list', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.governanceDictionary.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.list('requirement_type')).resolves.toEqual([]);
|
||||||
|
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.governanceDictionary.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds default task categories without reading legacy AppData on first governance list', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const seeded = [{ id: 'cat-1', name: '前端开发', group: 'development' }];
|
||||||
|
prisma.taskCategory.findMany
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce(seeded);
|
||||||
|
prisma.taskCategory.create.mockResolvedValue(seeded[0]);
|
||||||
|
|
||||||
|
await expect(service.list('task_category')).resolves.toEqual(seeded);
|
||||||
|
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
id: 'cat-1',
|
||||||
|
name: '前端开发',
|
||||||
|
group: 'development',
|
||||||
|
code: 'frontend_development',
|
||||||
|
isSystem: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds default development and testing task categories when relation table is empty', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.taskCategory.findMany
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([{ id: 'cat-1', name: '前端开发', group: 'development' }]);
|
||||||
|
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
|
||||||
|
|
||||||
|
await service.list('task_category');
|
||||||
|
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ id: 'cat-1', name: '前端开发', group: 'development' }),
|
||||||
|
});
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ id: 'cat-test-functional', name: '功能测试', group: 'testing' }),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('exports task categories and governance dictionaries together', async () => {
|
it('exports task categories and governance dictionaries together', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
|
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
|
||||||
|
|||||||
@@ -33,17 +33,28 @@ export class GovernanceService {
|
|||||||
private readonly rbacService: RbacService,
|
private readonly rbacService: RbacService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(kind: GovernanceDictionaryKind) {
|
async list(kind: GovernanceDictionaryKind) {
|
||||||
assertDictionaryKind(kind);
|
assertDictionaryKind(kind);
|
||||||
if (kind === 'task_category') {
|
if (kind === 'task_category') {
|
||||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
const rows = await this.findTaskCategories();
|
||||||
|
if (rows.length > 0) return rows;
|
||||||
|
await this.seedDefaultTaskCategories();
|
||||||
|
return this.findTaskCategories();
|
||||||
}
|
}
|
||||||
|
return this.findGovernanceDictionaries(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGovernanceDictionaries(kind: GovernanceDictionaryKind) {
|
||||||
return this.prisma.governanceDictionary.findMany({
|
return this.prisma.governanceDictionary.findMany({
|
||||||
where: { kind, deletedAt: null },
|
where: { kind, deletedAt: null },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findTaskCategories() {
|
||||||
|
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: GovernanceDictionaryInput) {
|
async create(input: GovernanceDictionaryInput) {
|
||||||
assertDictionaryKind(input.kind);
|
assertDictionaryKind(input.kind);
|
||||||
const actorId = requireText(input.actorId, 'actorId');
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
@@ -212,6 +223,20 @@ export class GovernanceService {
|
|||||||
requiredPermissions: ['governance:manage'],
|
requiredPermissions: ['governance:manage'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async seedDefaultTaskCategories() {
|
||||||
|
for (const item of DEFAULT_TASK_CATEGORIES) {
|
||||||
|
await this.prisma.taskCategory.create({
|
||||||
|
data: {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
group: item.group,
|
||||||
|
code: item.code ?? null,
|
||||||
|
isSystem: item.isSystem,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
||||||
@@ -225,3 +250,26 @@ function requireText(value: string | undefined | null, field: string): string {
|
|||||||
if (!normalized) throw new BadRequestException(`${field} is required`);
|
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TASK_CATEGORIES: Array<{ id: string; code: string; name: string; group: string; isSystem: boolean }> = [
|
||||||
|
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-test-functional', code: 'test_functional', name: '功能测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', isSystem: true },
|
||||||
|
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', isSystem: true },
|
||||||
|
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', isSystem: true },
|
||||||
|
];
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function buildAppData(): Record<string, any> {
|
|||||||
status: 'adopted',
|
status: 'adopted',
|
||||||
priority: 'P1',
|
priority: 'P1',
|
||||||
creator: 'Product manager',
|
creator: 'Product manager',
|
||||||
|
productOwner: 'pm',
|
||||||
createdAt: '2026-01-02T08:00:00.000Z',
|
createdAt: '2026-01-02T08:00:00.000Z',
|
||||||
updatedAt: '2026-01-02T09:00:00.000Z',
|
updatedAt: '2026-01-02T09:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -62,6 +63,11 @@ function buildAppData(): Record<string, any> {
|
|||||||
name: 'Product manager',
|
name: 'Product manager',
|
||||||
username: 'pm',
|
username: 'pm',
|
||||||
email: '',
|
email: '',
|
||||||
|
phone: '13000000001',
|
||||||
|
roleId: 'role-pm',
|
||||||
|
departmentId: 'dept-product',
|
||||||
|
password: 'Ftb12345',
|
||||||
|
isSystem: false,
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -287,7 +293,17 @@ describe('mapAppDataToV22Rows', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(result.users).toEqual([
|
expect(result.users).toEqual([
|
||||||
expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }),
|
expect.objectContaining({
|
||||||
|
id: 'member-pm',
|
||||||
|
email: 'pm@local.ftb',
|
||||||
|
name: 'Product manager',
|
||||||
|
username: 'pm',
|
||||||
|
phone: '13000000001',
|
||||||
|
roleId: 'role-pm',
|
||||||
|
departmentId: 'dept-product',
|
||||||
|
password: 'Ftb12345',
|
||||||
|
isSystem: false,
|
||||||
|
}),
|
||||||
expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }),
|
expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }),
|
||||||
expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }),
|
expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }),
|
||||||
]);
|
]);
|
||||||
@@ -302,6 +318,7 @@ describe('mapAppDataToV22Rows', () => {
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
platform: 'Web,App',
|
platform: 'Web,App',
|
||||||
creatorId: 'member-pm',
|
creatorId: 'member-pm',
|
||||||
|
productOwnerId: 'member-pm',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(result.devTasks).toEqual([
|
expect(result.devTasks).toEqual([
|
||||||
@@ -442,4 +459,15 @@ describe('mapAppDataToV22Rows', () => {
|
|||||||
reason: 'missing version partition context',
|
reason: 'missing version partition context',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves legacy person references by member username', () => {
|
||||||
|
const appData = buildAppData();
|
||||||
|
appData.requirements.requirements[0].creator = 'pm';
|
||||||
|
appData['version-plans'][0].owner = 'pm';
|
||||||
|
|
||||||
|
const result = mapAppDataToV22Rows(appData);
|
||||||
|
|
||||||
|
expect(result.requirements[0]).toEqual(expect.objectContaining({ creatorId: 'member-pm' }));
|
||||||
|
expect(result.versionPlans[0]).toEqual(expect.objectContaining({ ownerId: 'member-pm' }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ interface UserRow {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
username?: string;
|
||||||
|
departmentId?: string;
|
||||||
|
roleId?: string;
|
||||||
|
phone?: string;
|
||||||
|
password?: string;
|
||||||
|
isSystem?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,7 @@ interface RequirementRow {
|
|||||||
sourceTarget?: string;
|
sourceTarget?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
creatorId?: string;
|
creatorId?: string;
|
||||||
|
productOwnerId?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -94,6 +101,7 @@ interface VersionPlanRow {
|
|||||||
actualStartAt?: string;
|
actualStartAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
|
tasks: unknown[];
|
||||||
requirementCoverage: unknown[];
|
requirementCoverage: unknown[];
|
||||||
logs: unknown[];
|
logs: unknown[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -383,6 +391,12 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
email: nextEmail(email),
|
email: nextEmail(email),
|
||||||
name: stringField(member, 'name') ?? username,
|
name: stringField(member, 'name') ?? username,
|
||||||
avatar: stringField(member, 'avatar'),
|
avatar: stringField(member, 'avatar'),
|
||||||
|
username,
|
||||||
|
departmentId: stringField(member, 'departmentId'),
|
||||||
|
roleId: stringField(member, 'roleId') ?? 'member',
|
||||||
|
phone: stringField(member, 'phone') ?? '',
|
||||||
|
password: stringField(member, 'password') ?? '',
|
||||||
|
isSystem: booleanField(member, 'isSystem') ?? false,
|
||||||
createdAt: stringField(member, 'createdAt'),
|
createdAt: stringField(member, 'createdAt'),
|
||||||
updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'),
|
updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'),
|
||||||
});
|
});
|
||||||
@@ -418,6 +432,10 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
sourceTarget: stringField(requirement, 'sourceTarget'),
|
sourceTarget: stringField(requirement, 'sourceTarget'),
|
||||||
platform: platforms.join(',') || stringField(requirement, 'platform'),
|
platform: platforms.join(',') || stringField(requirement, 'platform'),
|
||||||
creatorId: resolveUserId(stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), userIdByReference),
|
creatorId: resolveUserId(stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), userIdByReference),
|
||||||
|
productOwnerId: resolveUserId(
|
||||||
|
stringField(requirement, 'productOwnerId') ?? stringField(requirement, 'productOwner'),
|
||||||
|
userIdByReference,
|
||||||
|
),
|
||||||
createdAt: stringField(requirement, 'createdAt'),
|
createdAt: stringField(requirement, 'createdAt'),
|
||||||
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
|
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
|
||||||
};
|
};
|
||||||
@@ -460,6 +478,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
actualStartAt: stringField(plan, 'actualStartAt'),
|
actualStartAt: stringField(plan, 'actualStartAt'),
|
||||||
completedAt: stringField(plan, 'completedAt'),
|
completedAt: stringField(plan, 'completedAt'),
|
||||||
resultUrl: stringField(plan, 'resultUrl'),
|
resultUrl: stringField(plan, 'resultUrl'),
|
||||||
|
tasks: unknownArray(plan.tasks),
|
||||||
requirementCoverage: unknownArray(plan.requirementCoverage),
|
requirementCoverage: unknownArray(plan.requirementCoverage),
|
||||||
logs: unknownArray(plan.logs),
|
logs: unknownArray(plan.logs),
|
||||||
createdAt: stringField(plan, 'createdAt'),
|
createdAt: stringField(plan, 'createdAt'),
|
||||||
@@ -878,8 +897,9 @@ function uniqueEmailFactory() {
|
|||||||
function buildUserIdByReference(users: UserRow[]): Map<string, string> {
|
function buildUserIdByReference(users: UserRow[]): Map<string, string> {
|
||||||
const refs = new Map<string, string>();
|
const refs = new Map<string, string>();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
refs.set(user.id, user.id);
|
for (const ref of [user.id, user.name, user.username, user.email]) {
|
||||||
refs.set(user.name, user.id);
|
if (ref) refs.set(ref, user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/server/src/modules/product/product.service.spec.ts
Normal file
46
apps/server/src/modules/product/product.service.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { ProductService } from './product.service';
|
||||||
|
|
||||||
|
function buildPrismaMock() {
|
||||||
|
return {
|
||||||
|
product: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
appData: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProductService relation source', () => {
|
||||||
|
it('does not fall back to products-overview AppData when relation products are empty', async () => {
|
||||||
|
const prisma = buildPrismaMock();
|
||||||
|
prisma.product.findMany.mockResolvedValue([]);
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
|
||||||
|
});
|
||||||
|
const service = new ProductService(prisma as any);
|
||||||
|
|
||||||
|
await expect(service.findAll()).resolves.toEqual([]);
|
||||||
|
await expect(service.findAllWithChildren()).resolves.toEqual([]);
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to products-overview AppData when a relation product is missing', async () => {
|
||||||
|
const prisma = buildPrismaMock();
|
||||||
|
prisma.product.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
|
key: 'products-overview',
|
||||||
|
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
|
||||||
|
});
|
||||||
|
const service = new ProductService(prisma as any);
|
||||||
|
|
||||||
|
await expect(service.findOne('legacy-product')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,32 +3,6 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { CreateProductDto } from './dto/create-product.dto';
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
import { UpdateProductDto } from './dto/update-product.dto';
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
|
|
||||||
type ProductOverviewItem = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
projects?: { id: string; name: string; description?: string; createdAt?: string }[];
|
|
||||||
versions?: {
|
|
||||||
id: string;
|
|
||||||
productId?: string;
|
|
||||||
projectId?: string | null;
|
|
||||||
name: string;
|
|
||||||
status?: string;
|
|
||||||
currentStage?: string | null;
|
|
||||||
startDate?: string | null;
|
|
||||||
expectedReleaseDate?: string | null;
|
|
||||||
releaseDate?: string | null;
|
|
||||||
members?: unknown[];
|
|
||||||
progress?: unknown[];
|
|
||||||
priority?: string | number | null;
|
|
||||||
links?: unknown;
|
|
||||||
createdAt?: string;
|
|
||||||
}[];
|
|
||||||
_count?: { requirements?: number; projects?: number; versions?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductService {
|
export class ProductService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -44,15 +18,7 @@ export class ProductService {
|
|||||||
_count: { select: { requirements: true, projects: true, versions: true } },
|
_count: { select: { requirements: true, projects: true, versions: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (products.length > 0) return products;
|
return products;
|
||||||
|
|
||||||
const overview = await this.getAppDataOverview();
|
|
||||||
if (!overview) return [];
|
|
||||||
return overview.map((product) => {
|
|
||||||
const normalized = this.normalizeOverviewProduct(product);
|
|
||||||
const { projects: _projects, versions: _versions, ...rest } = normalized;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllWithChildren() {
|
async findAllWithChildren() {
|
||||||
@@ -87,11 +53,7 @@ export class ProductService {
|
|||||||
_count: { select: { requirements: true, projects: true, versions: true } },
|
_count: { select: { requirements: true, projects: true, versions: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product));
|
return products.map((product) => this.normalizeRelationProduct(product));
|
||||||
|
|
||||||
const overview = await this.getAppDataOverview();
|
|
||||||
if (!overview) return [];
|
|
||||||
return overview.map((product) => this.normalizeOverviewProduct(product));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
@@ -108,14 +70,7 @@ export class ProductService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (product) return product;
|
if (product) return product;
|
||||||
|
throw new NotFoundException('产品不存在');
|
||||||
const overview = await this.getAppDataOverview();
|
|
||||||
const fallback = overview?.find((item) => item.id === id);
|
|
||||||
if (!fallback) throw new NotFoundException('产品不存在');
|
|
||||||
return {
|
|
||||||
...this.normalizeOverviewProduct(fallback),
|
|
||||||
requirements: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateProductDto) {
|
async update(id: string, dto: UpdateProductDto) {
|
||||||
@@ -133,30 +88,6 @@ export class ProductService {
|
|||||||
if (!exists) throw new NotFoundException('产品不存在');
|
if (!exists) throw new NotFoundException('产品不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAppDataOverview(): Promise<ProductOverviewItem[] | null> {
|
|
||||||
const row = await this.prisma.appData.findUnique({
|
|
||||||
where: { key: 'products-overview' },
|
|
||||||
});
|
|
||||||
if (!Array.isArray(row?.value)) return null;
|
|
||||||
return row.value as unknown as ProductOverviewItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeOverviewProduct(product: ProductOverviewItem) {
|
|
||||||
const projects = product.projects ?? [];
|
|
||||||
const versions = product.versions ?? [];
|
|
||||||
return {
|
|
||||||
...product,
|
|
||||||
description: product.description ?? '',
|
|
||||||
projects,
|
|
||||||
versions,
|
|
||||||
_count: {
|
|
||||||
requirements: product._count?.requirements ?? 0,
|
|
||||||
projects: projects.length,
|
|
||||||
versions: versions.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeRelationProduct(product: any) {
|
private normalizeRelationProduct(product: any) {
|
||||||
const projects = product.projects ?? [];
|
const projects = product.projects ?? [];
|
||||||
const versions = (product.versions ?? []).map((version: any) => ({
|
const versions = (product.versions ?? []).map((version: any) => ({
|
||||||
|
|||||||
@@ -56,4 +56,12 @@ export class CreateRequirementDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
creatorId?: string;
|
creatorId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
productOwnerId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
productOwner?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { RequirementService } from './requirement.service';
|
|||||||
describe('RequirementService with V2.2 composite requirement key', () => {
|
describe('RequirementService with V2.2 composite requirement key', () => {
|
||||||
const makeService = () => {
|
const makeService = () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
requirement: {
|
requirement: {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
@@ -18,6 +21,44 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves creator names to user ids before requirement relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||||
|
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||||
|
|
||||||
|
await service.create('product-1', {
|
||||||
|
code: 'REQ-001',
|
||||||
|
title: 'Payment',
|
||||||
|
creatorId: '产品经理',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||||
|
expect(prisma.requirement.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
creatorId: 'pm-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves product owner names to user ids before requirement relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||||
|
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||||
|
|
||||||
|
await service.create('product-1', {
|
||||||
|
code: 'REQ-001',
|
||||||
|
title: 'Payment',
|
||||||
|
productOwner: '产品经理',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||||
|
expect(prisma.requirement.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
productOwnerId: 'pm-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('creates requirements with a partition-key-scoped business code', async () => {
|
it('creates requirements with a partition-key-scoped business code', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||||
@@ -96,7 +137,10 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
|||||||
{ title: { contains: 'login', mode: 'insensitive' } },
|
{ title: { contains: 'login', mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: { creator: { select: { id: true, name: true } } },
|
include: {
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
productOwner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: 2,
|
take: 2,
|
||||||
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
||||||
@@ -132,6 +176,24 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates requirement product owner by id plus product id', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||||
|
prisma.requirement.findFirst.mockResolvedValue({
|
||||||
|
id: 'req-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
status: 'pending_review',
|
||||||
|
});
|
||||||
|
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
||||||
|
|
||||||
|
await service.update('product-1', 'req-1', { productOwner: '产品经理' } as any);
|
||||||
|
|
||||||
|
expect(prisma.requirement.update).toHaveBeenCalledWith({
|
||||||
|
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
|
||||||
|
data: { productOwnerId: 'pm-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updates frontend pool fields by id plus product id', async () => {
|
it('updates frontend pool fields by id plus product id', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.requirement.findFirst.mockResolvedValue({
|
prisma.requirement.findFirst.mockResolvedValue({
|
||||||
@@ -225,3 +287,13 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
|||||||
expect(prisma.requirement.update).not.toHaveBeenCalled();
|
expect(prisma.requirement.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { RequirementStatus } from '@ftb/shared';
|
import { RequirementStatus } from '@ftb/shared';
|
||||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||||
@@ -15,6 +16,11 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
|
|||||||
[RequirementStatus.CLOSED]: [],
|
[RequirementStatus.CLOSED]: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REQUIREMENT_USER_INCLUDE = {
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
productOwner: { select: { id: true, name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface RequirementListQuery {
|
export interface RequirementListQuery {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
@@ -38,8 +44,8 @@ function createFallbackRequirementCode() {
|
|||||||
export class RequirementService {
|
export class RequirementService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
create(productId: string, dto: CreateRequirementDto) {
|
async create(productId: string, dto: CreateRequirementDto) {
|
||||||
const data = this.toRequirementData(dto);
|
const data = await this.toRequirementData(dto);
|
||||||
return this.prisma.requirement.create({
|
return this.prisma.requirement.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
@@ -79,7 +85,7 @@ export class RequirementService {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: { creator: { select: { id: true, name: true } } },
|
include: REQUIREMENT_USER_INCLUDE,
|
||||||
orderBy: parseRequirementSort(query.sort),
|
orderBy: parseRequirementSort(query.sort),
|
||||||
take: limit + 1,
|
take: limit + 1,
|
||||||
...(query.cursor
|
...(query.cursor
|
||||||
@@ -100,7 +106,7 @@ export class RequirementService {
|
|||||||
async findOne(productId: string, id: string) {
|
async findOne(productId: string, id: string) {
|
||||||
const req = await this.prisma.requirement.findFirst({
|
const req = await this.prisma.requirement.findFirst({
|
||||||
where: { id, productId },
|
where: { id, productId },
|
||||||
include: { creator: { select: { id: true, name: true } } },
|
include: REQUIREMENT_USER_INCLUDE,
|
||||||
});
|
});
|
||||||
if (!req) throw new NotFoundException('需求不存在');
|
if (!req) throw new NotFoundException('需求不存在');
|
||||||
return req;
|
return req;
|
||||||
@@ -110,7 +116,7 @@ export class RequirementService {
|
|||||||
await this.findOne(productId, id);
|
await this.findOne(productId, id);
|
||||||
return this.prisma.requirement.update({
|
return this.prisma.requirement.update({
|
||||||
where: { id_productId: { id, productId } },
|
where: { id_productId: { id, productId } },
|
||||||
data: this.toRequirementData(dto),
|
data: await this.toRequirementData(dto),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +139,11 @@ export class RequirementService {
|
|||||||
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
|
private async toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
|
||||||
const type = dto.type ?? dto.typeId;
|
const type = dto.type ?? dto.typeId;
|
||||||
const platform = dto.platform ?? arrayToCsv(dto.platforms);
|
const platform = dto.platform ?? arrayToCsv(dto.platforms);
|
||||||
const priority = parsePriorityValue(dto.priority);
|
const priority = parsePriorityValue(dto.priority);
|
||||||
|
const productOwnerReference = dto.productOwnerId ?? dto.productOwner;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(dto.code !== undefined && { code: dto.code }),
|
...(dto.code !== undefined && { code: dto.code }),
|
||||||
@@ -148,7 +155,10 @@ export class RequirementService {
|
|||||||
...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }),
|
...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }),
|
||||||
...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }),
|
...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }),
|
||||||
...(platform !== undefined && { platform: emptyToNull(platform) }),
|
...(platform !== undefined && { platform: emptyToNull(platform) }),
|
||||||
...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }),
|
...(dto.creatorId !== undefined && { creatorId: await resolveUserReference(this.prisma, dto.creatorId) }),
|
||||||
|
...(dto.productOwnerId !== undefined || dto.productOwner !== undefined
|
||||||
|
? { productOwnerId: await resolveUserReference(this.prisma, productOwnerReference) }
|
||||||
|
: {}),
|
||||||
...(priority !== undefined && { priority }),
|
...(priority !== undefined && { priority }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ describe('TestCaseService domain writes', () => {
|
|||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -27,6 +30,49 @@ describe('TestCaseService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves assignee and creator names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', 产品经理: 'pm-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
title: '登录冒烟',
|
||||||
|
assigneeId: '测试',
|
||||||
|
createdBy: '产品经理',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.testCase.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 'qa-1',
|
||||||
|
creatorId: 'pm-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves names while batch creating copied or AI-generated test cases', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', AI: 'system-ai' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.testCase.createMany.mockResolvedValue({ count: 1 });
|
||||||
|
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }]);
|
||||||
|
|
||||||
|
await service.createMany('version-1', [
|
||||||
|
{ caseNo: 'TC-101', title: 'AI 用例', assigneeId: '测试', createdBy: 'AI' },
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
expect(prisma.testCase.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [
|
||||||
|
expect.objectContaining({
|
||||||
|
assigneeId: 'qa-1',
|
||||||
|
creatorId: 'system-ai',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('creates test cases directly under a version partition', async () => {
|
it('creates test cases directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -139,3 +185,13 @@ describe('TestCaseService domain writes', () => {
|
|||||||
expect(prisma.testCase.update).not.toHaveBeenCalled();
|
expect(prisma.testCase.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||||
@@ -14,9 +15,10 @@ export class TestCaseService {
|
|||||||
|
|
||||||
async create(versionId: string, dto: CreateTestCaseDto) {
|
async create(versionId: string, dto: CreateTestCaseDto) {
|
||||||
const version = await this.ensureVersion(versionId);
|
const version = await this.ensureVersion(versionId);
|
||||||
|
const testCaseData = await this.toTestCaseData(dto);
|
||||||
const item = await this.prisma.testCase.create({
|
const item = await this.prisma.testCase.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toTestCaseData(dto),
|
...testCaseData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -32,15 +34,15 @@ export class TestCaseService {
|
|||||||
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
|
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
|
||||||
if (dtos.length === 0) return { items: [], activities: [] };
|
if (dtos.length === 0) return { items: [], activities: [] };
|
||||||
const version = await this.ensureVersion(versionId);
|
const version = await this.ensureVersion(versionId);
|
||||||
const rows = dtos.map((dto) => ({
|
const rows = await Promise.all(dtos.map(async (dto) => ({
|
||||||
...this.toTestCaseData(dto),
|
...(await this.toTestCaseData(dto)),
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
|
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
status: dto.status ?? 'pending',
|
status: dto.status ?? 'pending',
|
||||||
}));
|
})));
|
||||||
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
|
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
|
||||||
const items = await this.prisma.testCase.findMany({
|
const items = await this.prisma.testCase.findMany({
|
||||||
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
||||||
@@ -62,7 +64,7 @@ export class TestCaseService {
|
|||||||
await this.ensureTestCaseInVersion(versionId, id);
|
await this.ensureTestCaseInVersion(versionId, id);
|
||||||
const item = await this.prisma.testCase.update({
|
const item = await this.prisma.testCase.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTestCaseData(dto),
|
data: await this.toTestCaseData(dto),
|
||||||
});
|
});
|
||||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
@@ -90,7 +92,7 @@ export class TestCaseService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
private async toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
||||||
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
||||||
@@ -101,8 +103,10 @@ export class TestCaseService {
|
|||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
|
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
|
||||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
|
...(dto.creatorId !== undefined || dto.createdBy !== undefined
|
||||||
|
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
|
||||||
|
: {}),
|
||||||
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
|
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
|
||||||
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
|
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
|
||||||
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),
|
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ describe('V22QueryService', () => {
|
|||||||
expect(result.devTasks).toEqual([{ id: 'dev-1' }]);
|
expect(result.devTasks).toEqual([{ id: 'dev-1' }]);
|
||||||
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
|
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
|
||||||
where: { versionId: 'version-1' },
|
where: { versionId: 'version-1' },
|
||||||
include: { creator: { select: { id: true, name: true } } },
|
include: {
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
productOwner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
|
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
|
||||||
@@ -95,7 +98,10 @@ describe('V22QueryService', () => {
|
|||||||
{ title: { contains: 'login', mode: 'insensitive' } },
|
{ title: { contains: 'login', mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: { creator: { select: { id: true, name: true } } },
|
include: {
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
productOwner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: 2,
|
take: 2,
|
||||||
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
||||||
@@ -129,7 +135,13 @@ describe('V22QueryService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to AppData workspace rows when relation tables are empty', async () => {
|
it('rejects missing workspace userId with a client error instead of a TypeError', async () => {
|
||||||
|
const service = new V22QueryService(buildPrismaMock() as any);
|
||||||
|
|
||||||
|
await expect(service.getWorkspaceData(undefined as any)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to legacy AppData workspace rows when relation tables are empty', async () => {
|
||||||
const prisma = buildPrismaMock();
|
const prisma = buildPrismaMock();
|
||||||
prisma.versionPlan.findMany.mockResolvedValue([]);
|
prisma.versionPlan.findMany.mockResolvedValue([]);
|
||||||
prisma.devTask.findMany.mockResolvedValue([]);
|
prisma.devTask.findMany.mockResolvedValue([]);
|
||||||
@@ -170,14 +182,8 @@ describe('V22QueryService', () => {
|
|||||||
|
|
||||||
const result = await service.getWorkspaceData('m-8');
|
const result = await service.getWorkspaceData('m-8');
|
||||||
|
|
||||||
expect(result.versionPlans).toEqual([
|
expect(result).toEqual({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
|
||||||
expect.objectContaining({
|
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||||
id: 'plan-1',
|
|
||||||
ownerId: 'm-8',
|
|
||||||
title: '案例学习 V1.3 产品方案',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(prisma.appData.findMany).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => {
|
it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
|
||||||
import { mapAppDataToV22Rows } from '../migration/app-data-v22.mapper';
|
|
||||||
|
|
||||||
interface RequirementQuery {
|
interface RequirementQuery {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
@@ -21,8 +19,16 @@ interface XiaobaoWarningQuery {
|
|||||||
manager?: string;
|
manager?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRows = {
|
||||||
|
versionPlans: unknown[];
|
||||||
|
devTasks: unknown[];
|
||||||
|
testCases: unknown[];
|
||||||
|
bugs: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
const REQUIREMENT_CREATOR_INCLUDE = {
|
const REQUIREMENT_CREATOR_INCLUDE = {
|
||||||
creator: { select: { id: true, name: true } },
|
creator: { select: { id: true, name: true } },
|
||||||
|
productOwner: { select: { id: true, name: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -105,8 +111,8 @@ export class V22QueryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaceData(userId: string): Promise<WorkspaceRows> {
|
async getWorkspaceData(userId?: string): Promise<WorkspaceRows> {
|
||||||
const normalizedUserId = userId.trim();
|
const normalizedUserId = userId?.trim();
|
||||||
if (!normalizedUserId) throw new BadRequestException('userId is required');
|
if (!normalizedUserId) throw new BadRequestException('userId is required');
|
||||||
|
|
||||||
const [versionPlans, devTasks, testCases, bugs] = await Promise.all([
|
const [versionPlans, devTasks, testCases, bugs] = await Promise.all([
|
||||||
@@ -128,39 +134,9 @@ export class V22QueryService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const relationResult = { versionPlans, devTasks, testCases, bugs };
|
|
||||||
if (hasWorkspaceRows(relationResult)) return relationResult;
|
|
||||||
|
|
||||||
const fallback = await this.getWorkspaceDataFromAppData(normalizedUserId);
|
|
||||||
if (hasWorkspaceRows(fallback)) return fallback;
|
|
||||||
|
|
||||||
return { versionPlans, devTasks, testCases, bugs };
|
return { versionPlans, devTasks, testCases, bugs };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getWorkspaceDataFromAppData(userId: string): Promise<WorkspaceRows> {
|
|
||||||
const rows = await this.prisma.appData.findMany({
|
|
||||||
where: { key: { in: [...APP_DATA_KEYS] } },
|
|
||||||
select: { key: true, value: true },
|
|
||||||
});
|
|
||||||
const snapshot = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
||||||
const mapped = mapAppDataToV22Rows(snapshot);
|
|
||||||
|
|
||||||
return {
|
|
||||||
versionPlans: mapped.versionPlans
|
|
||||||
.filter((plan) => plan.ownerId === userId && plan.status !== 'completed')
|
|
||||||
.sort(comparePlanRows),
|
|
||||||
devTasks: mapped.devTasks
|
|
||||||
.filter((task) => task.assigneeId === userId && task.status !== 'submitted')
|
|
||||||
.sort(comparePriorityThenUpdatedRows),
|
|
||||||
testCases: mapped.testCases
|
|
||||||
.filter((testCase) => testCase.assigneeId === userId && !['passed', 'failed', 'blocked'].includes(testCase.status))
|
|
||||||
.sort(comparePriorityThenUpdatedRows),
|
|
||||||
bugs: mapped.bugs
|
|
||||||
.filter((bug) => bug.assigneeId === userId && ['open', 'fixing', 'fixed', 'verifying'].includes(bug.status))
|
|
||||||
.sort(comparePriorityThenUpdatedRows),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
||||||
if (query.manager === 'true') {
|
if (query.manager === 'true') {
|
||||||
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||||
@@ -234,49 +210,6 @@ export class V22QueryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceRows = {
|
|
||||||
versionPlans: unknown[];
|
|
||||||
devTasks: unknown[];
|
|
||||||
testCases: unknown[];
|
|
||||||
bugs: unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PriorityUpdatedRow = {
|
|
||||||
priority?: number;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlanSortRow = {
|
|
||||||
expectedEndAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function hasWorkspaceRows(rows: WorkspaceRows): boolean {
|
|
||||||
return rows.versionPlans.length > 0
|
|
||||||
|| rows.devTasks.length > 0
|
|
||||||
|| rows.testCases.length > 0
|
|
||||||
|| rows.bugs.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function comparePlanRows(a: PlanSortRow, b: PlanSortRow): number {
|
|
||||||
const endDiff = dateSortValue(a.expectedEndAt, Number.POSITIVE_INFINITY)
|
|
||||||
- dateSortValue(b.expectedEndAt, Number.POSITIVE_INFINITY);
|
|
||||||
if (endDiff !== 0) return endDiff;
|
|
||||||
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function comparePriorityThenUpdatedRows(a: PriorityUpdatedRow, b: PriorityUpdatedRow): number {
|
|
||||||
const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0);
|
|
||||||
if (priorityDiff !== 0) return priorityDiff;
|
|
||||||
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateSortValue(value: string | undefined, fallback: number): number {
|
|
||||||
if (!value) return fallback;
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) ? time : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLimit(raw?: string): number {
|
function parseLimit(raw?: string): number {
|
||||||
const parsed = raw ? Number(raw) : 50;
|
const parsed = raw ? Number(raw) : 50;
|
||||||
if (!Number.isFinite(parsed)) return 50;
|
if (!Number.isFinite(parsed)) return 50;
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export class CreateVersionPlanDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
tasks?: unknown[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
linkedRequirementIds?: string[];
|
linkedRequirementIds?: string[];
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ describe('VersionPlanService domain writes', () => {
|
|||||||
const makeService = () => {
|
const makeService = () => {
|
||||||
const workActivity = {
|
const workActivity = {
|
||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
|
markXiaobaoSummaryDirty: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -26,6 +30,58 @@ describe('VersionPlanService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves plan owner names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 张三: 'member-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案', ownerId: 'member-1' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
type: 'product',
|
||||||
|
title: '产品方案',
|
||||||
|
owner: '张三',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||||
|
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
ownerId: 'member-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears unresolved plan owner references instead of writing invalid foreign keys', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||||
|
id: 'plan-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'pending',
|
||||||
|
title: '产品方案',
|
||||||
|
ownerId: 'member-1',
|
||||||
|
});
|
||||||
|
prisma.versionPlan.update.mockResolvedValue({
|
||||||
|
id: 'plan-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'pending',
|
||||||
|
title: '产品方案',
|
||||||
|
ownerId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('version-1', 'plan-1', { owner: '不存在的成员' } as any);
|
||||||
|
|
||||||
|
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'plan-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
ownerId: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('creates version plans directly under a version partition', async () => {
|
it('creates version plans directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -61,6 +117,64 @@ describe('VersionPlanService domain writes', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists research direction tasks on create and update', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const tasks = [{ id: 'task-1', title: '竞品分析', status: 'pending' }];
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.versionPlan.create.mockResolvedValue({
|
||||||
|
id: 'plan-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'pending',
|
||||||
|
title: '调研方案',
|
||||||
|
ownerId: 'member-1',
|
||||||
|
tasks,
|
||||||
|
});
|
||||||
|
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||||
|
id: 'plan-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'pending',
|
||||||
|
title: '调研方案',
|
||||||
|
ownerId: 'member-1',
|
||||||
|
tasks,
|
||||||
|
});
|
||||||
|
prisma.versionPlan.update.mockResolvedValue({
|
||||||
|
id: 'plan-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'pending',
|
||||||
|
title: '调研方案',
|
||||||
|
ownerId: 'member-1',
|
||||||
|
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
type: 'research',
|
||||||
|
title: '调研方案',
|
||||||
|
owner: 'member-1',
|
||||||
|
tasks,
|
||||||
|
} as any);
|
||||||
|
await service.update('version-1', 'plan-1', {
|
||||||
|
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tasks,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'plan-1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updates plan status inside version scope and records activity evidence', async () => {
|
it('updates plan status inside version scope and records activity evidence', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.versionPlan.findFirst.mockResolvedValue({
|
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||||
@@ -107,3 +221,13 @@ describe('VersionPlanService domain writes', () => {
|
|||||||
expect(prisma.versionPlan.update).not.toHaveBeenCalled();
|
expect(prisma.versionPlan.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||||
@@ -14,9 +15,10 @@ export class VersionPlanService {
|
|||||||
|
|
||||||
async create(versionId: string, dto: CreateVersionPlanDto) {
|
async create(versionId: string, dto: CreateVersionPlanDto) {
|
||||||
const version = await this.ensureVersion(versionId);
|
const version = await this.ensureVersion(versionId);
|
||||||
|
const planData = await this.toPlanData(dto);
|
||||||
const item = await this.prisma.versionPlan.create({
|
const item = await this.prisma.versionPlan.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toPlanData(dto),
|
...planData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -38,7 +40,7 @@ export class VersionPlanService {
|
|||||||
|
|
||||||
async update(versionId: string, id: string, dto: UpdateVersionPlanDto) {
|
async update(versionId: string, id: string, dto: UpdateVersionPlanDto) {
|
||||||
const current = await this.ensurePlanInVersion(versionId, id);
|
const current = await this.ensurePlanInVersion(versionId, id);
|
||||||
const data = this.toPlanData(dto);
|
const data = await this.toPlanData(dto);
|
||||||
if (dto.status === 'in_progress' && !current.actualStartAt) {
|
if (dto.status === 'in_progress' && !current.actualStartAt) {
|
||||||
data.actualStartAt = new Date();
|
data.actualStartAt = new Date();
|
||||||
}
|
}
|
||||||
@@ -69,12 +71,14 @@ export class VersionPlanService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
private async toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.type !== undefined && { type: dto.type }),
|
...(dto.type !== undefined && { type: dto.type }),
|
||||||
...(dto.title !== undefined && { title: dto.title }),
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}),
|
...(dto.owner !== undefined || dto.ownerId !== undefined
|
||||||
|
? { ownerId: await resolveUserReference(this.prisma, dto.ownerId ?? dto.owner) }
|
||||||
|
: {}),
|
||||||
...(dto.startTime !== undefined || dto.expectedStartAt !== undefined
|
...(dto.startTime !== undefined || dto.expectedStartAt !== undefined
|
||||||
? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) }
|
? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -84,6 +88,7 @@ export class VersionPlanService {
|
|||||||
...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }),
|
...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }),
|
||||||
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
|
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
|
||||||
...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }),
|
...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }),
|
||||||
|
...(dto.tasks !== undefined && { tasks: toJsonInput(dto.tasks) }),
|
||||||
...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined
|
...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined
|
||||||
? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) }
|
? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -25,13 +25,30 @@ const KIND_LABEL: Record<GovernanceKind, string> = {
|
|||||||
requirement_source: '需求来源',
|
requirement_source: '需求来源',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TASK_CATEGORY_GROUPS = [
|
||||||
|
{ value: 'development', label: '开发' },
|
||||||
|
{ value: 'testing', label: '测试' },
|
||||||
|
{ value: 'implementation', label: '实施' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_TYPE_GROUPS = [
|
||||||
|
{ value: 'customer', label: '客户' },
|
||||||
|
{ value: 'internal', label: '内部' },
|
||||||
|
{ value: 'operation', label: '运营' },
|
||||||
|
{ value: 'aftersale', label: '售后' },
|
||||||
|
{ value: 'market', label: '市场' },
|
||||||
|
{ value: 'competitor', label: '竞品' },
|
||||||
|
{ value: 'management', label: '管理层' },
|
||||||
|
];
|
||||||
|
|
||||||
function GovernancePageInner() {
|
function GovernancePageInner() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||||
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
||||||
const [items, setItems] = useState<GovernanceItem[]>([]);
|
const [items, setItems] = useState<GovernanceItem[]>([]);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [group, setGroup] = useState('other');
|
const [group, setGroup] = useState(defaultGroupForKind('task_category'));
|
||||||
const [exportText, setExportText] = useState('');
|
const [exportText, setExportText] = useState('');
|
||||||
const actorId = user?.id ?? '';
|
const actorId = user?.id ?? '';
|
||||||
const permissions = role?.permissions ?? [];
|
const permissions = role?.permissions ?? [];
|
||||||
@@ -45,9 +62,13 @@ function GovernancePageInner() {
|
|||||||
void reload().catch(() => setItems([]));
|
void reload().catch(() => setItems([]));
|
||||||
}, [kind]);
|
}, [kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGroup(defaultGroupForKind(kind));
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!actorId || !name.trim()) return;
|
if (!actorId || !name.trim()) return;
|
||||||
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
|
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group: groupForPayload(kind, group) });
|
||||||
setName('');
|
setName('');
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
@@ -99,9 +120,15 @@ function GovernancePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
|
<div className={`grid gap-2 border-b border-[var(--line)] p-3 ${groupOptionsForKind(kind) ? 'grid-cols-[1fr_140px_auto]' : 'grid-cols-[1fr_auto]'}`}>
|
||||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
{groupOptionsForKind(kind) && (
|
||||||
|
<select value={group} onChange={(event) => setGroup(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
{groupOptionsForKind(kind)?.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||||
<Plus className="h-3.5 w-3.5" /> 添加
|
<Plus className="h-3.5 w-3.5" /> 添加
|
||||||
</button>
|
</button>
|
||||||
@@ -143,6 +170,22 @@ function GovernancePageInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupOptionsForKind(kind: GovernanceKind) {
|
||||||
|
if (kind === 'task_category') return TASK_CATEGORY_GROUPS;
|
||||||
|
if (kind === 'requirement_source') return SOURCE_TYPE_GROUPS;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGroupForKind(kind: GovernanceKind) {
|
||||||
|
if (kind === 'task_category') return 'development';
|
||||||
|
if (kind === 'requirement_source') return 'customer';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupForPayload(kind: GovernanceKind, group: string) {
|
||||||
|
return groupOptionsForKind(kind) ? group : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GovernancePage() {
|
export default function GovernancePage() {
|
||||||
return (
|
return (
|
||||||
<RouteGuard permission="governance:manage">
|
<RouteGuard permission="governance:manage">
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ function OvertimePageContent() {
|
|||||||
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||||
|
|
||||||
@@ -327,6 +326,7 @@ function OvertimePageContent() {
|
|||||||
versions={allVersions}
|
versions={allVersions}
|
||||||
reasons={reasons}
|
reasons={reasons}
|
||||||
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
|
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
|
||||||
|
onVersionChange={(version) => fetchRequirements({ productId: version.productId, versionId: version.id })}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
createRecord(data as any);
|
createRecord(data as any);
|
||||||
@@ -408,13 +408,14 @@ function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClic
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
|
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onVersionChange, onClose, onSubmit }: {
|
||||||
defaultPerson: string;
|
defaultPerson: string;
|
||||||
products: { id: string; name: string }[];
|
products: { id: string; name: string }[];
|
||||||
projects: { id: string; name: string; productId: string }[];
|
projects: { id: string; name: string; productId: string }[];
|
||||||
versions: { id: string; name: string; projectId?: string }[];
|
versions: { id: string; name: string; productId: string; projectId?: string }[];
|
||||||
reasons: { id: string; name: string }[];
|
reasons: { id: string; name: string }[];
|
||||||
requirements: { id: string; title: string; versionId?: string }[];
|
requirements: { id: string; title: string; versionId?: string }[];
|
||||||
|
onVersionChange: (version: { id: string; productId: string }) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: any) => void;
|
onSubmit: (data: any) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -469,7 +470,18 @@ function OvertimeModal({ defaultPerson, products, projects, versions, reasons, r
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">版本 *</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">版本 *</label>
|
||||||
<select value={versionId} onChange={(e) => setVersionId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
<select
|
||||||
|
value={versionId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextVersionId = e.target.value;
|
||||||
|
setVersionId(nextVersionId);
|
||||||
|
setRequirementId('');
|
||||||
|
const version = versions.find((item) => item.id === nextVersionId);
|
||||||
|
if (version) onVersionChange(version);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
>
|
||||||
<option value="">选择版本</option>
|
<option value="">选择版本</option>
|
||||||
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
|||||||
import { RequirementTable } from '@/components/product/RequirementTable';
|
import { RequirementTable } from '@/components/product/RequirementTable';
|
||||||
import { RequirementForm } from '@/components/product/RequirementForm';
|
import { RequirementForm } from '@/components/product/RequirementForm';
|
||||||
import { ProductForm } from '@/components/product/ProductForm';
|
import { ProductForm } from '@/components/product/ProductForm';
|
||||||
|
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
|
||||||
|
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'requirements' as const, label: '需求池' },
|
{ key: 'requirements' as const, label: '需求池' },
|
||||||
@@ -29,16 +33,19 @@ export default function ProductDetailPage() {
|
|||||||
deleteRequirement,
|
deleteRequirement,
|
||||||
} = useRequirementStore();
|
} = useRequirementStore();
|
||||||
const { records, fetchRecords } = useOvertimeStore();
|
const { records, fetchRecords } = useOvertimeStore();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const roles = useMemberStore((state) => state.roles);
|
||||||
|
|
||||||
const [showReqForm, setShowReqForm] = useState(false);
|
const [showReqForm, setShowReqForm] = useState(false);
|
||||||
const [editingReq, setEditingReq] = useState<any>(null);
|
const [editingReq, setEditingReq] = useState<any>(null);
|
||||||
const [editingProduct, setEditingProduct] = useState(false);
|
const [editingProduct, setEditingProduct] = useState(false);
|
||||||
|
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
|
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
|
||||||
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null);
|
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProduct(productId);
|
fetchProduct(productId);
|
||||||
fetchRequirements();
|
fetchRequirements({ productId });
|
||||||
fetchRecords();
|
fetchRecords();
|
||||||
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);
|
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);
|
||||||
|
|
||||||
@@ -47,6 +54,10 @@ export default function ProductDetailPage() {
|
|||||||
if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter);
|
if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter);
|
||||||
return list;
|
return list;
|
||||||
}, [requirements, productId, reqStatusFilter]);
|
}, [requirements, productId, reqStatusFilter]);
|
||||||
|
const currentPermissions = useMemo(
|
||||||
|
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||||||
|
[roles, user?.roleId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFilterChange = (status: RequirementStatus | null) => {
|
const handleFilterChange = (status: RequirementStatus | null) => {
|
||||||
setReqStatusFilter(status);
|
setReqStatusFilter(status);
|
||||||
@@ -93,7 +104,8 @@ export default function ProductDetailPage() {
|
|||||||
{currentProduct.name}
|
{currentProduct.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingProduct(true)}
|
onClick={() => setEditingProduct(true)}
|
||||||
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
|
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
|
||||||
@@ -215,6 +227,14 @@ export default function ProductDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnalysisContextDrawer
|
||||||
|
open={showAnalysisDrawer}
|
||||||
|
title={`产品智能分析 · ${currentProduct.name}`}
|
||||||
|
context={{ surface: 'product_detail', productId: currentProduct.id }}
|
||||||
|
permissions={currentPermissions}
|
||||||
|
onClose={() => setShowAnalysisDrawer(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from
|
|||||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
||||||
|
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
|
||||||
|
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
|
||||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||||
@@ -327,19 +329,31 @@ export default function ProjectDetailPage() {
|
|||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
|
||||||
|
|
||||||
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
|
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project) return;
|
||||||
|
void fetchRequirements({ productId: project.productId, projectId });
|
||||||
|
project.versions.forEach((version) => {
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchDevTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
void fetchBugs({ versionId: version.id });
|
||||||
|
});
|
||||||
|
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases, project, projectId]);
|
||||||
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const currentUserName = user?.name || '';
|
const currentUserName = user?.name || '';
|
||||||
const { roles } = useMemberStore();
|
const { roles } = useMemberStore();
|
||||||
|
const currentPermissions = useMemo(
|
||||||
|
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||||||
|
[roles, user?.roleId],
|
||||||
|
);
|
||||||
const isSuperAdmin = useMemo(() => {
|
const isSuperAdmin = useMemo(() => {
|
||||||
const r = roles.find((x) => x.id === user?.roleId);
|
const r = roles.find((x) => x.id === user?.roleId);
|
||||||
return !!r && r.permissions.includes('*');
|
return !!r && r.permissions.includes('*');
|
||||||
@@ -435,8 +449,11 @@ export default function ProjectDetailPage() {
|
|||||||
<span className="ml-2 text-[var(--ink-muted)]">/</span>
|
<span className="ml-2 text-[var(--ink-muted)]">/</span>
|
||||||
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span>
|
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="h-3 w-3" /><span>{project.productName}</span>
|
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-xs text-[var(--ink-soft)]">
|
||||||
|
<Package className="h-3 w-3" /><span>{project.productName}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -482,6 +499,13 @@ export default function ProjectDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AnalysisContextDrawer
|
||||||
|
open={showAnalysisDrawer}
|
||||||
|
title={`项目智能分析 · ${project.name}`}
|
||||||
|
context={{ surface: 'project_detail', projectId: project.id }}
|
||||||
|
permissions={currentPermissions}
|
||||||
|
onClose={() => setShowAnalysisDrawer(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,23 @@ function ProjectsPageContent() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
|
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
|
||||||
|
|
||||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
|
useEffect(() => {
|
||||||
|
overview.forEach((product) => {
|
||||||
|
void fetchRequirements({ productId: product.id });
|
||||||
|
});
|
||||||
|
}, [fetchRequirements, overview]);
|
||||||
|
useEffect(() => {
|
||||||
|
allVersions.forEach((version) => {
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchDevTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
});
|
||||||
|
}, [allVersions, fetchDevTasks, fetchPlans, fetchTestCases]);
|
||||||
|
|
||||||
const versionProgressMap = useMemo(
|
const versionProgressMap = useMemo(
|
||||||
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
|
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
|
||||||
[allVersions, plans, requirements, devTasks, testCases],
|
[allVersions, plans, requirements, devTasks, testCases],
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
Lightbulb,
|
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -42,7 +41,6 @@ import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-l
|
|||||||
import { Pagination } 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 { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
|
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
|
||||||
import { loadV22RequirementsPage } from '@/lib/v22-api';
|
import { loadV22RequirementsPage } from '@/lib/v22-api';
|
||||||
@@ -199,9 +197,6 @@ function RequirementsPageContent() {
|
|||||||
const {
|
const {
|
||||||
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
||||||
sourceTargets, types, platforms,
|
sourceTargets, types, platforms,
|
||||||
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
|
||||||
addType, updateType, deleteType,
|
|
||||||
addPlatform, updatePlatform, deletePlatform,
|
|
||||||
loaded: requirementsLoaded,
|
loaded: requirementsLoaded,
|
||||||
} = useRequirementStore();
|
} = useRequirementStore();
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
@@ -233,7 +228,6 @@ function RequirementsPageContent() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
|
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
|
||||||
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
|
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
|
||||||
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
|
|
||||||
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');
|
||||||
@@ -274,6 +268,23 @@ function RequirementsPageContent() {
|
|||||||
if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId);
|
if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId);
|
||||||
return allVersions;
|
return allVersions;
|
||||||
}, [allVersions, selectedScope]);
|
}, [allVersions, selectedScope]);
|
||||||
|
const hydrateRequirementStoreFallback = useCallback(async () => {
|
||||||
|
const pendingLoads: Array<Promise<void>> = [];
|
||||||
|
if (selectedScope.type === 'product') {
|
||||||
|
pendingLoads.push(fetchRequirements({ productId: selectedScope.productId }));
|
||||||
|
} else if (selectedScope.type === 'project') {
|
||||||
|
const project = allProjects.find((item) => item.id === selectedScope.projectId);
|
||||||
|
if (project) pendingLoads.push(fetchRequirements({ productId: project.productId, projectId: selectedScope.projectId }));
|
||||||
|
} else {
|
||||||
|
for (const product of overview) {
|
||||||
|
pendingLoads.push(fetchRequirements({ productId: product.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const version of scopedVersions) {
|
||||||
|
pendingLoads.push(fetchDevTasks({ versionId: version.id }));
|
||||||
|
}
|
||||||
|
await Promise.all(pendingLoads);
|
||||||
|
}, [allProjects, fetchDevTasks, fetchRequirements, overview, scopedVersions, selectedScope]);
|
||||||
|
|
||||||
const filterResetKey = useMemo(() => JSON.stringify({
|
const filterResetKey = useMemo(() => JSON.stringify({
|
||||||
selectedScopeKey,
|
selectedScopeKey,
|
||||||
@@ -319,9 +330,8 @@ function RequirementsPageContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (v22RequirementQuery && !v22RequirementsFailed) return;
|
if (v22RequirementQuery && !v22RequirementsFailed) return;
|
||||||
fetchRequirements();
|
void hydrateRequirementStoreFallback();
|
||||||
fetchDevTasks();
|
}, [hydrateRequirementStoreFallback, v22RequirementQuery, v22RequirementsFailed]);
|
||||||
}, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setV22RequirementsFailed(false);
|
setV22RequirementsFailed(false);
|
||||||
@@ -354,8 +364,7 @@ function RequirementsPageContent() {
|
|||||||
setV22Requirements([]);
|
setV22Requirements([]);
|
||||||
setV22NextCursor(undefined);
|
setV22NextCursor(undefined);
|
||||||
setV22RequirementsFailed(true);
|
setV22RequirementsFailed(true);
|
||||||
fetchRequirements();
|
void hydrateRequirementStoreFallback();
|
||||||
fetchDevTasks();
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setV22RequirementsLoading(false);
|
if (!cancelled) setV22RequirementsLoading(false);
|
||||||
@@ -364,7 +373,7 @@ function RequirementsPageContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]);
|
}, [hydrateRequirementStoreFallback, page, v22RequirementQuery]);
|
||||||
|
|
||||||
const selectedScopeTitle = useMemo(() => {
|
const selectedScopeTitle = useMemo(() => {
|
||||||
if (selectedScope.type === 'product') {
|
if (selectedScope.type === 'product') {
|
||||||
@@ -472,7 +481,7 @@ function RequirementsPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
await fetchRequirements();
|
await hydrateRequirementStoreFallback();
|
||||||
setEditingReq(null);
|
setEditingReq(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -480,10 +489,6 @@ function RequirementsPageContent() {
|
|||||||
const handleSelectScope = (scope: RequirementScopeSelection) => {
|
const handleSelectScope = (scope: RequirementScopeSelection) => {
|
||||||
setAutoScopeSelected(true);
|
setAutoScopeSelected(true);
|
||||||
setSelectedScope(scope);
|
setSelectedScope(scope);
|
||||||
if (scope.type === 'all') {
|
|
||||||
fetchRequirements();
|
|
||||||
fetchDevTasks();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -552,13 +557,6 @@ function RequirementsPageContent() {
|
|||||||
<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
|
|
||||||
onClick={() => { fetchRequirements(); setDrawerType('source'); }}
|
|
||||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
|
||||||
>
|
|
||||||
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
|
|
||||||
来源管理
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
|
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
@@ -888,41 +886,6 @@ function RequirementsPageContent() {
|
|||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingReq(null);
|
setEditingReq(null);
|
||||||
}}
|
}}
|
||||||
onOpenDrawer={(type) => setDrawerType(type)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drawer */}
|
|
||||||
{drawerType === 'source' && (
|
|
||||||
<SourceDrawer
|
|
||||||
open={true}
|
|
||||||
items={sourceTargets}
|
|
||||||
onClose={() => setDrawerType(null)}
|
|
||||||
onAdd={addSourceTarget}
|
|
||||||
onUpdate={updateSourceTarget}
|
|
||||||
onDelete={deleteSourceTarget}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{drawerType === 'type' && (
|
|
||||||
<DictDrawer
|
|
||||||
open={true}
|
|
||||||
title="需求类型管理"
|
|
||||||
items={types}
|
|
||||||
onClose={() => setDrawerType(null)}
|
|
||||||
onAdd={addType}
|
|
||||||
onUpdate={updateType}
|
|
||||||
onDelete={deleteType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{drawerType === 'platform' && (
|
|
||||||
<DictDrawer
|
|
||||||
open={true}
|
|
||||||
title="支持端管理"
|
|
||||||
items={platforms}
|
|
||||||
onClose={() => setDrawerType(null)}
|
|
||||||
onAdd={addPlatform}
|
|
||||||
onUpdate={updatePlatform}
|
|
||||||
onDelete={deletePlatform}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
@@ -17,6 +17,8 @@ import { PlanTab } from '@/components/version/PlanTab';
|
|||||||
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
||||||
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
|
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
|
||||||
import { BugTab } from '@/components/bug/BugTab';
|
import { BugTab } from '@/components/bug/BugTab';
|
||||||
|
import { AnalysisContextDrawer } from '@/components/analysis/AnalysisContextDrawer';
|
||||||
|
import { AnalysisEntryButton } from '@/components/analysis/AnalysisEntryButton';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
@@ -135,10 +137,12 @@ export default function VersionDetailPage() {
|
|||||||
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
||||||
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
||||||
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
|
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
|
||||||
|
const currentPermissions = currentRole?.permissions ?? [];
|
||||||
const memberCandidates = useMemo(
|
const memberCandidates = useMemo(
|
||||||
() => allMembers.map((member) => ({
|
() => allMembers.map((member) => ({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
username: member.username,
|
||||||
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
|
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
|
||||||
})),
|
})),
|
||||||
[allMembers, departments],
|
[allMembers, departments],
|
||||||
@@ -157,8 +161,15 @@ export default function VersionDetailPage() {
|
|||||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||||
|
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
|
||||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||||
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
|
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
|
||||||
|
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||||
|
const fetchScopedRequirements = useCallback((force = false) => {
|
||||||
|
if (!version) return Promise.resolve();
|
||||||
|
if (force) return fetchRequirements({ productId: version.productId, versionId, force: true });
|
||||||
|
return fetchRequirements({ productId: version.productId, versionId });
|
||||||
|
}, [fetchRequirements, version, versionId]);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -178,43 +189,44 @@ export default function VersionDetailPage() {
|
|||||||
fetchRecords();
|
fetchRecords();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!version) return;
|
||||||
if (activeTab === 'requirements') {
|
if (activeTab === 'requirements') {
|
||||||
fetchRequirements();
|
fetchScopedRequirements();
|
||||||
fetchDevTasks();
|
fetchDevTasks({ versionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
|
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
|
||||||
fetchRequirements();
|
fetchScopedRequirements();
|
||||||
fetchPlans();
|
fetchPlans({ versionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab === 'tasks') {
|
if (activeTab === 'tasks') {
|
||||||
fetchRequirements();
|
fetchScopedRequirements();
|
||||||
fetchDevTasks();
|
fetchDevTasks({ versionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab === 'testcases') {
|
if (activeTab === 'testcases') {
|
||||||
fetchRequirements();
|
fetchScopedRequirements();
|
||||||
fetchDevTasks();
|
fetchDevTasks({ versionId });
|
||||||
fetchTestCases();
|
fetchTestCases({ versionId });
|
||||||
fetchBugs();
|
fetchBugs({ versionId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab === 'bugs') {
|
if (activeTab === 'bugs') {
|
||||||
fetchRequirements();
|
fetchScopedRequirements();
|
||||||
fetchTestCases();
|
fetchTestCases({ versionId });
|
||||||
fetchBugs();
|
fetchBugs({ versionId });
|
||||||
}
|
}
|
||||||
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]);
|
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, version, versionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showRecommendModal) return;
|
if (!showRecommendModal) return;
|
||||||
fetchRecords();
|
fetchRecords();
|
||||||
fetchRequirements();
|
fetchScopedRequirements(true);
|
||||||
fetchPlans();
|
fetchPlans({ versionId });
|
||||||
fetchDevTasks();
|
fetchDevTasks({ versionId });
|
||||||
fetchTestCases();
|
fetchTestCases({ versionId });
|
||||||
fetchBugs();
|
fetchBugs({ versionId });
|
||||||
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]);
|
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, showRecommendModal, versionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setRecommendationDataReady(false);
|
setRecommendationDataReady(false);
|
||||||
@@ -224,8 +236,7 @@ export default function VersionDetailPage() {
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [fetchMembers, fetchCategories]);
|
}, [fetchMembers, fetchCategories]);
|
||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
const storeVersionScope = useMemo(
|
||||||
const appDataVersionScope = useMemo(
|
|
||||||
() => version
|
() => version
|
||||||
? buildVersionDataScope({
|
? buildVersionDataScope({
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
@@ -246,19 +257,20 @@ export default function VersionDetailPage() {
|
|||||||
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
|
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
|
||||||
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
|
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
|
||||||
const loadAllVersionStores = () => {
|
const loadAllVersionStores = () => {
|
||||||
|
if (!version) return;
|
||||||
fetchRecords();
|
fetchRecords();
|
||||||
fetchRequirements();
|
fetchRequirements({ productId: version.productId, versionId, force: true });
|
||||||
fetchPlans();
|
fetchPlans({ versionId });
|
||||||
fetchDevTasks();
|
fetchDevTasks({ versionId });
|
||||||
fetchTestCases();
|
fetchTestCases({ versionId });
|
||||||
fetchBugs();
|
fetchBugs({ versionId });
|
||||||
};
|
};
|
||||||
const versionScope = useMemo(
|
const versionScope = useMemo(
|
||||||
() => selectVersionDataScope({
|
() => selectVersionDataScope({
|
||||||
appDataScope: appDataVersionScope,
|
storeScope: storeVersionScope,
|
||||||
v22Scope: versionWriteStoresReady ? null : v22Scope,
|
v22Scope,
|
||||||
}),
|
}),
|
||||||
[appDataVersionScope, v22Scope, versionWriteStoresReady],
|
[storeVersionScope, v22Scope],
|
||||||
);
|
);
|
||||||
const releaseProgress = versionScope
|
const releaseProgress = versionScope
|
||||||
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
|
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
|
||||||
@@ -323,15 +335,12 @@ export default function VersionDetailPage() {
|
|||||||
.some((permission) => canWriteVersionPermission(permission));
|
.some((permission) => canWriteVersionPermission(permission));
|
||||||
|
|
||||||
const scopedVersionData = versionScope!;
|
const scopedVersionData = versionScope!;
|
||||||
const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData;
|
const displayRequirements = scopedVersionData.requirements;
|
||||||
const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements;
|
const displayPlans = scopedVersionData.plans;
|
||||||
const displayPlans = plansLoaded ? appDataScopedVersionData.plans : scopedVersionData.plans;
|
const displayDevTasks = scopedVersionData.devTasks;
|
||||||
const displayDevTasks = devTasksLoaded ? appDataScopedVersionData.devTasks : scopedVersionData.devTasks;
|
const displayTestCases = scopedVersionData.testCases;
|
||||||
const displayTestCases = testCasesLoaded ? appDataScopedVersionData.testCases : scopedVersionData.testCases;
|
const displayBugs = scopedVersionData.bugs;
|
||||||
const displayBugs = bugsLoaded ? appDataScopedVersionData.bugs : scopedVersionData.bugs;
|
const displayRequirementIds = scopedVersionData.requirementIds;
|
||||||
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) => {
|
||||||
@@ -432,7 +441,10 @@ export default function VersionDetailPage() {
|
|||||||
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
|
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
|
||||||
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
|
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">{renderActions()}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<AnalysisEntryButton onClick={() => setShowAnalysisDrawer(true)} />
|
||||||
|
{renderActions()}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
@@ -1059,8 +1071,10 @@ export default function VersionDetailPage() {
|
|||||||
version={version}
|
version={version}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
currentUserName={user?.name ?? ''}
|
currentUserName={user?.name ?? ''}
|
||||||
|
currentUserReference={user?.username ?? user?.id ?? user?.name ?? ''}
|
||||||
planType={pt}
|
planType={pt}
|
||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
|
allMembers={memberCandidates}
|
||||||
linkedRequirements={versionLinkedReqs}
|
linkedRequirements={versionLinkedReqs}
|
||||||
allRequirements={displayRequirements}
|
allRequirements={displayRequirements}
|
||||||
readOnly={versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])}
|
readOnly={versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])}
|
||||||
@@ -1184,6 +1198,13 @@ export default function VersionDetailPage() {
|
|||||||
onClose={() => setShowMemberModal(false)}
|
onClose={() => setShowMemberModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<AnalysisContextDrawer
|
||||||
|
open={showAnalysisDrawer}
|
||||||
|
title={`版本智能分析 · ${version.name}`}
|
||||||
|
context={{ surface: 'version_detail', versionId: version.id }}
|
||||||
|
permissions={currentPermissions}
|
||||||
|
onClose={() => setShowAnalysisDrawer(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,16 +94,9 @@ function VersionsPageContent() {
|
|||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
|
|
||||||
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
|
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
|
||||||
|
|
||||||
const { plans, fetchPlans } = useVersionPlanStore();
|
const { plans, fetchPlans } = useVersionPlanStore();
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
|
||||||
|
|
||||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
|
||||||
|
|
||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
|
||||||
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const currentUserName = user?.name || '';
|
const currentUserName = user?.name || '';
|
||||||
@@ -125,6 +118,14 @@ function VersionsPageContent() {
|
|||||||
if (!v.members || v.members.length === 0) return true;
|
if (!v.members || v.members.length === 0) return true;
|
||||||
return v.members.some((m) => m.name === currentUserName);
|
return v.members.some((m) => m.name === currentUserName);
|
||||||
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
|
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
|
||||||
|
useEffect(() => {
|
||||||
|
for (const version of allVersions) {
|
||||||
|
void fetchRequirements({ productId: version.productId, versionId: version.id });
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchDevTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
}
|
||||||
|
}, [allVersions, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases]);
|
||||||
const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
|
const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
|
||||||
|
|
||||||
// Compute overall progress per version from actual data
|
// Compute overall progress per version from actual data
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
||||||
|
import type { AnalysisResponse } from '@ftb/shared';
|
||||||
import {
|
import {
|
||||||
BotMessageSquare,
|
BotMessageSquare,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
|
||||||
|
import { requestAnalysis } from '@/lib/analysis-api';
|
||||||
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
||||||
import {
|
import {
|
||||||
createWenfanConversationRecord,
|
createWenfanConversationRecord,
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
shouldShowGenericHelpSuggestions,
|
shouldShowGenericHelpSuggestions,
|
||||||
type HelpSearchResult,
|
type HelpSearchResult,
|
||||||
} from '@/lib/wenfan-help-search';
|
} from '@/lib/wenfan-help-search';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
type ChatMessage =
|
type ChatMessage =
|
||||||
| {
|
| {
|
||||||
@@ -54,6 +59,18 @@ type ChatMessage =
|
|||||||
type: 'fallback';
|
type: 'fallback';
|
||||||
content: string;
|
content: string;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'analysis';
|
||||||
|
response: AnalysisResponse;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
type: 'analysis_error';
|
||||||
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_MESSAGES: ChatMessage[] = [
|
const INITIAL_MESSAGES: ChatMessage[] = [
|
||||||
@@ -62,7 +79,7 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
type: 'intro',
|
type: 'intro',
|
||||||
content:
|
content:
|
||||||
'第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,8 +92,14 @@ export default function WenfanXiaobaoPage() {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const roles = useMemberStore((state) => state.roles);
|
||||||
|
|
||||||
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||||
|
const currentPermissions = useMemo(
|
||||||
|
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
||||||
|
[roles, user?.roleId],
|
||||||
|
);
|
||||||
const userQuestionCount = useMemo(
|
const userQuestionCount = useMemo(
|
||||||
() => messages.filter((message) => message.role === 'user').length,
|
() => messages.filter((message) => message.role === 'user').length,
|
||||||
[messages],
|
[messages],
|
||||||
@@ -111,52 +134,78 @@ export default function WenfanXiaobaoPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function askQuestion(rawQuestion: string) {
|
async function askQuestion(rawQuestion: string) {
|
||||||
const question = rawQuestion.trim();
|
const question = rawQuestion.trim();
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
|
||||||
const nextUserQuestionCount = userQuestionCount + 1;
|
|
||||||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: question,
|
content: question,
|
||||||
};
|
};
|
||||||
|
const optimisticMessages = [...messages, userMessage];
|
||||||
|
|
||||||
const assistantMessage: ChatMessage =
|
setMessages(optimisticMessages);
|
||||||
results.length > 0
|
|
||||||
? {
|
|
||||||
id: `article-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
type: 'article',
|
|
||||||
result: results[0],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
id: `fallback-${Date.now()}`,
|
|
||||||
role: 'assistant',
|
|
||||||
type: 'fallback',
|
|
||||||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
|
||||||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextMessages = [...messages, userMessage, assistantMessage];
|
|
||||||
|
|
||||||
setMessages(nextMessages);
|
|
||||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
|
||||||
setInput('');
|
setInput('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await requestAnalysis(
|
||||||
|
{ question, context: { surface: 'ai_assistant' } },
|
||||||
|
currentPermissions,
|
||||||
|
);
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: `analysis-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'analysis',
|
||||||
|
response: analysis,
|
||||||
|
};
|
||||||
|
const nextMessages = [...optimisticMessages, assistantMessage];
|
||||||
|
|
||||||
|
setMessages(nextMessages);
|
||||||
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||||
|
const nextUserQuestionCount = userQuestionCount + 1;
|
||||||
|
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||||||
|
const analysisErrorMessage: ChatMessage = {
|
||||||
|
id: `analysis-error-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'analysis_error',
|
||||||
|
content: '业务分析暂不可用,我先用内置帮助继续回答。',
|
||||||
|
};
|
||||||
|
const assistantMessage: ChatMessage =
|
||||||
|
results.length > 0
|
||||||
|
? {
|
||||||
|
id: `article-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'article',
|
||||||
|
result: results[0],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: `fallback-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
type: 'fallback',
|
||||||
|
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||||
|
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||||
|
};
|
||||||
|
const nextMessages = [...optimisticMessages, analysisErrorMessage, assistantMessage];
|
||||||
|
|
||||||
|
setMessages(nextMessages);
|
||||||
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
askQuestion(input);
|
void askQuestion(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
askQuestion(input);
|
void askQuestion(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +276,7 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">内置帮助</span>
|
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">只读分析 · 内置帮助</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
@@ -247,7 +296,7 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<button
|
<button
|
||||||
key={question}
|
key={question}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => askQuestion(question)}
|
onClick={() => void askQuestion(question)}
|
||||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||||
>
|
>
|
||||||
{question}
|
{question}
|
||||||
@@ -330,6 +379,12 @@ function MessageRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
||||||
|
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
|
||||||
|
{message.type === 'analysis_error' && (
|
||||||
|
<p className="rounded-[22px] border border-[#e2e8f0] bg-white/75 px-4 py-3 text-[13px] leading-6 text-[#64748b] shadow-sm backdrop-blur">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||||
{message.type === 'fallback' && (
|
{message.type === 'fallback' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api';
|
|||||||
import { selectWorkspaceCollections } from '@/lib/workspace-v22-source';
|
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; productId: string; productName: string; projectName: string; status: VersionStatus };
|
||||||
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
|
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
|
||||||
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
|
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
|
||||||
|
|
||||||
@@ -56,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, loaded: plansLoaded } = useVersionPlanStore();
|
const { plans, fetchPlans } = useVersionPlanStore();
|
||||||
const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore();
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||||
const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore();
|
const { bugs, fetchBugs } = 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);
|
||||||
@@ -81,6 +81,16 @@ export default function WorkspacePage() {
|
|||||||
const userName = user?.name ?? '';
|
const userName = user?.name ?? '';
|
||||||
const workspaceUserKey = userId || userName;
|
const workspaceUserKey = userId || userName;
|
||||||
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
|
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
|
||||||
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
const hydrateWorkspaceStoreFallback = useCallback(() => {
|
||||||
|
for (const version of allVersions) {
|
||||||
|
void fetchRequirements({ productId: version.productId, versionId: version.id });
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
void fetchBugs({ versionId: version.id });
|
||||||
|
}
|
||||||
|
}, [allVersions, fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceUserKey.trim()) {
|
if (!workspaceUserKey.trim()) {
|
||||||
@@ -112,26 +122,19 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return;
|
if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return;
|
||||||
void fetchPlans();
|
hydrateWorkspaceStoreFallback();
|
||||||
void fetchRequirements();
|
}, [hydrateWorkspaceStoreFallback, v22WorkspaceFailed, workspaceUserKey]);
|
||||||
void fetchTasks();
|
|
||||||
void fetchTestCases();
|
|
||||||
void fetchBugs();
|
|
||||||
}, [
|
|
||||||
fetchBugs,
|
|
||||||
fetchPlans,
|
|
||||||
fetchRequirements,
|
|
||||||
fetchTasks,
|
|
||||||
fetchTestCases,
|
|
||||||
v22WorkspaceFailed,
|
|
||||||
workspaceUserKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
|
||||||
|
|
||||||
const versionMap = useMemo(() => {
|
const versionMap = useMemo(() => {
|
||||||
const map = new Map<string, WorkspaceVersionContext>();
|
const map = new Map<string, WorkspaceVersionContext>();
|
||||||
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status }));
|
allVersions.forEach((v) => map.set(v.id, {
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
productId: v.productId,
|
||||||
|
productName: v.productName,
|
||||||
|
projectName: v.projectName,
|
||||||
|
status: v.status,
|
||||||
|
}));
|
||||||
return map;
|
return map;
|
||||||
}, [allVersions]);
|
}, [allVersions]);
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ export default function WorkspacePage() {
|
|||||||
v22Loaded: v22WorkspaceLoaded,
|
v22Loaded: v22WorkspaceLoaded,
|
||||||
v22Failed: v22WorkspaceFailed,
|
v22Failed: v22WorkspaceFailed,
|
||||||
v22Data: v22WorkspaceData,
|
v22Data: v22WorkspaceData,
|
||||||
appData: { plans, devTasks, testCases, bugs },
|
storeData: { plans, devTasks, testCases, bugs },
|
||||||
}),
|
}),
|
||||||
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
|
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
|
||||||
);
|
);
|
||||||
@@ -227,25 +230,24 @@ export default function WorkspacePage() {
|
|||||||
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
|
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
|
||||||
const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => {
|
const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => {
|
||||||
const pendingLoads: Array<Promise<void>> = [];
|
const pendingLoads: Array<Promise<void>> = [];
|
||||||
if (!requirementsLoaded) pendingLoads.push(fetchRequirements());
|
const versionContext = versionMap.get(item.versionId);
|
||||||
if ((item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') && !plansLoaded) {
|
if (versionContext) {
|
||||||
pendingLoads.push(fetchPlans());
|
pendingLoads.push(fetchRequirements({ productId: versionContext.productId, versionId: item.versionId }));
|
||||||
}
|
}
|
||||||
if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks());
|
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
|
||||||
if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases());
|
pendingLoads.push(fetchPlans({ versionId: item.versionId }));
|
||||||
if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs());
|
}
|
||||||
|
if (item.type === 'devTask') pendingLoads.push(fetchTasks({ versionId: item.versionId }));
|
||||||
|
if (item.type === 'testCase') pendingLoads.push(fetchTestCases({ versionId: item.versionId }));
|
||||||
|
if (item.type === 'bug') pendingLoads.push(fetchBugs({ versionId: item.versionId }));
|
||||||
await Promise.all(pendingLoads);
|
await Promise.all(pendingLoads);
|
||||||
}, [
|
}, [
|
||||||
bugsLoaded,
|
|
||||||
devTasksLoaded,
|
|
||||||
fetchBugs,
|
|
||||||
fetchPlans,
|
fetchPlans,
|
||||||
fetchRequirements,
|
fetchRequirements,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
fetchTestCases,
|
fetchTestCases,
|
||||||
plansLoaded,
|
versionMap,
|
||||||
requirementsLoaded,
|
fetchBugs,
|
||||||
testCasesLoaded,
|
|
||||||
]);
|
]);
|
||||||
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
|
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
|
||||||
await ensureWorkspaceDrawerStores(item);
|
await ensureWorkspaceDrawerStores(item);
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||||||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai';
|
|
||||||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
import { attachXiaobaoRiskSuggestion } from '@/lib/xiaobao-risk-suggestion';
|
||||||
import {
|
import {
|
||||||
filterXiaobaoRiskWarnings,
|
filterXiaobaoRiskWarnings,
|
||||||
formatRemainingWork,
|
formatRemainingWork,
|
||||||
isXiaobaoWarningUpdated,
|
isXiaobaoWarningUpdated,
|
||||||
shouldSkipXiaobaoRiskInsightRequestForReadState,
|
|
||||||
sanitizeRiskInsight,
|
|
||||||
} from '@/lib/xiaobao-warning-view';
|
} from '@/lib/xiaobao-warning-view';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
@@ -57,19 +54,13 @@ function XiaobaoWarningContent() {
|
|||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
insightRequestAttempts,
|
insightRequestAttempts,
|
||||||
riskDataLoaded,
|
|
||||||
saveSnapshot,
|
saveSnapshot,
|
||||||
saveInsight,
|
|
||||||
beginInsightUpdate,
|
|
||||||
finishInsightUpdate,
|
|
||||||
today,
|
|
||||||
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||||
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
|
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
|
||||||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
const [selectedProductId, setSelectedProductId] = useState('');
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||||
const savedSnapshotKeysRef = useRef(new Set<string>());
|
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||||
const requestedInsightKeysRef = useRef(new Set<string>());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id) fetchReadStates();
|
if (user?.id) fetchReadStates();
|
||||||
@@ -89,52 +80,6 @@ function XiaobaoWarningContent() {
|
|||||||
});
|
});
|
||||||
}, [risks, saveSnapshot, snapshots]);
|
}, [risks, saveSnapshot, snapshots]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
risks.forEach((risk) => {
|
|
||||||
if (shouldSkipXiaobaoRiskInsightRequestForReadState(risk, readStates, user?.id, readStateLoaded)) return;
|
|
||||||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
|
||||||
const key = buildXiaobaoRiskInsightPendingKey(risk);
|
|
||||||
if (!shouldRequestRiskInsightWithRequestGate({
|
|
||||||
riskCacheLoaded: riskDataLoaded,
|
|
||||||
cache: insights,
|
|
||||||
current: risk,
|
|
||||||
previous,
|
|
||||||
lastRequestedAt: insightRequestAttempts[key],
|
|
||||||
})) return;
|
|
||||||
const signature = buildRiskInsightSignature(risk);
|
|
||||||
if (pendingInsightKeys.includes(key)) return;
|
|
||||||
if (requestedInsightKeysRef.current.has(key)) return;
|
|
||||||
requestedInsightKeysRef.current.add(key);
|
|
||||||
beginInsightUpdate(key);
|
|
||||||
requestRiskInsight(risk).then((response) => {
|
|
||||||
if (!response.ok) return;
|
|
||||||
return saveInsight({
|
|
||||||
versionId: risk.versionId,
|
|
||||||
riskSignature: signature,
|
|
||||||
insight: sanitizeRiskInsight(response.result),
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
providerInfo: { model: response.meta.model },
|
|
||||||
}).catch(() => {});
|
|
||||||
}).catch(() => {}).finally(() => {
|
|
||||||
finishInsightUpdate(key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
beginInsightUpdate,
|
|
||||||
finishInsightUpdate,
|
|
||||||
insights,
|
|
||||||
insightRequestAttempts,
|
|
||||||
pendingInsightKeys,
|
|
||||||
readStateLoaded,
|
|
||||||
readStates,
|
|
||||||
riskDataLoaded,
|
|
||||||
risks,
|
|
||||||
saveInsight,
|
|
||||||
snapshots,
|
|
||||||
today,
|
|
||||||
user?.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
|
|||||||
19
apps/web/components/analysis/AnalysisChart.tsx
Normal file
19
apps/web/components/analysis/AnalysisChart.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import type { UnifiedChartSpec } from '@ftb/shared';
|
||||||
|
import { toEChartsOption } from '@/lib/analysis-chart-renderer';
|
||||||
|
|
||||||
|
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||||||
|
|
||||||
|
export function AnalysisChart({ spec }: { spec: UnifiedChartSpec }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
|
||||||
|
{spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal file
107
apps/web/components/analysis/AnalysisContextDrawer.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type FormEvent, useState } from 'react';
|
||||||
|
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { Loader2, Send, X } from 'lucide-react';
|
||||||
|
import { requestAnalysis } from '@/lib/analysis-api';
|
||||||
|
import { AnalysisResultBlock } from './AnalysisResultBlock';
|
||||||
|
|
||||||
|
export function AnalysisContextDrawer({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
permissions,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
context: NonNullable<AnalysisRequest['context']>;
|
||||||
|
permissions: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [response, setResponse] = useState<AnalysisResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
async function ask(prompt: string) {
|
||||||
|
const value = prompt.trim();
|
||||||
|
if (!value || loading) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await requestAnalysis({ question: value, context }, permissions);
|
||||||
|
setResponse(result);
|
||||||
|
setQuestion('');
|
||||||
|
} catch {
|
||||||
|
setResponse({
|
||||||
|
ok: false,
|
||||||
|
code: 'AI_UNAVAILABLE',
|
||||||
|
message: '业务分析暂不可用,请稍后再试。',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
void ask(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||||
|
<aside className="flex h-full w-full max-w-2xl flex-col border-l border-white/60 bg-[#f8fafc]/90 shadow-2xl backdrop-blur-xl">
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/70 px-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[13px] font-semibold text-[#0f172a]">{title}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#64748b]">只读取当前上下文和已有权限数据,安全范围以后端为准。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-3 rounded-full p-2 text-[#64748b] transition-colors hover:bg-white hover:text-[#0f172a]"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="rounded-[28px] border border-white/70 bg-white/80 p-3 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={question}
|
||||||
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none bg-transparent px-2 py-2 text-[14px] leading-6 text-[#0f172a] outline-none placeholder:text-[#94a3b8]"
|
||||||
|
placeholder="例如:这个版本风险怎么样?需求完成趋势怎么样?"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
|
<span className="text-[11px] text-[#64748b]">不会创建、修改业务数据,也不会触发状态流转。</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !question.trim()}
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-[#0f172a] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#1e293b] disabled:cursor-not-allowed disabled:bg-[#cbd5e1]"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||||
|
{loading ? '分析中' : '开始分析'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{response ? (
|
||||||
|
<AnalysisResultBlock response={response} onAsk={(prompt) => void ask(prompt)} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[28px] border border-dashed border-[#dbe3ef] bg-white/55 p-6 text-[13px] leading-7 text-[#64748b]">
|
||||||
|
可以围绕当前页面提问,例如进度、风险、需求分布、成员负载或质量情况。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal file
25
apps/web/components/analysis/AnalysisEntryButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { BarChart3, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AnalysisEntryButton({
|
||||||
|
onClick,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const Icon = compact ? BarChart3 : Sparkles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-[#dbe3ef] bg-white/75 px-3 text-[12px] font-medium text-[#334155] shadow-sm backdrop-blur transition-colors hover:bg-white"
|
||||||
|
title="智能分析"
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
智能分析
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/components/analysis/AnalysisReport.tsx
Normal file
37
apps/web/components/analysis/AnalysisReport.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { AnalysisReport as AnalysisReportData } from '@ftb/shared';
|
||||||
|
import { EvidenceList } from './EvidenceList';
|
||||||
|
|
||||||
|
export function AnalysisReport({ report }: { report: AnalysisReportData }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/60 bg-white/75 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
|
<h3 className="text-[14px] font-semibold text-[#111827]">分析报告</h3>
|
||||||
|
<p className="mt-3 text-[14px] leading-7 text-[#334155]">{report.summary}</p>
|
||||||
|
<ReportSection title="关键发现" items={report.keyFindings} />
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-[12px] font-medium text-[#64748b]">数据依据</p>
|
||||||
|
<EvidenceList items={report.evidence} />
|
||||||
|
</div>
|
||||||
|
<ReportSection title="建议动作" items={report.suggestions} />
|
||||||
|
<div className="mt-4 rounded-2xl bg-[#f8fafc] p-3 text-[12px] leading-6 text-[#64748b]">
|
||||||
|
<p>{report.dataScope.timeDescription}</p>
|
||||||
|
<p>{report.dataScope.permissionDescription}</p>
|
||||||
|
<p>{report.dataScope.metricFormulaDescription}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSection({ title, items }: { title: string; items: string[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-[12px] font-medium text-[#64748b]">{title}</p>
|
||||||
|
<ul className="space-y-1.5 text-[13px] leading-6 text-[#334155]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item}>• {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal file
44
apps/web/components/analysis/AnalysisResultBlock.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { AnalysisChart } from './AnalysisChart';
|
||||||
|
import { AnalysisReport } from './AnalysisReport';
|
||||||
|
import { FollowUpActions } from './FollowUpActions';
|
||||||
|
import { InsightCard } from './InsightCard';
|
||||||
|
|
||||||
|
export function AnalysisResultBlock({
|
||||||
|
response,
|
||||||
|
onAsk,
|
||||||
|
}: {
|
||||||
|
response: AnalysisResponse;
|
||||||
|
onAsk: (prompt: string) => void;
|
||||||
|
}) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-[#e2e8f0] bg-white/80 p-5 text-[14px] leading-7 text-[#334155] shadow-sm backdrop-blur-xl">
|
||||||
|
<p className="font-medium text-[#0f172a]">{response.message}</p>
|
||||||
|
{response.clarificationOptions && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{response.clarificationOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.prompt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAsk(option.prompt)}
|
||||||
|
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] hover:bg-white"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InsightCard insight={response.insight} />
|
||||||
|
<AnalysisChart spec={response.chart} />
|
||||||
|
<AnalysisReport report={response.report} />
|
||||||
|
<FollowUpActions followUps={response.followUps} onAsk={onAsk} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/analysis/EvidenceList.tsx
Normal file
25
apps/web/components/analysis/EvidenceList.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { EvidenceItem } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function EvidenceList({ items }: { items: EvidenceItem[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${item.label}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-[#e2e8f0] bg-white/70 px-3 py-1.5 text-[12px] text-[#334155] shadow-sm backdrop-blur disabled:cursor-default"
|
||||||
|
disabled={!item.drilldown}
|
||||||
|
title={item.sourceLabel ?? item.sourceDomain}
|
||||||
|
>
|
||||||
|
<span className="text-[#64748b]">{item.label}</span>
|
||||||
|
<span className="ml-1 font-semibold text-[#0f172a]">
|
||||||
|
{item.value}
|
||||||
|
{item.unit ?? ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/web/components/analysis/FollowUpActions.tsx
Normal file
23
apps/web/components/analysis/FollowUpActions.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FollowUp } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function FollowUpActions({ followUps, onAsk }: { followUps: FollowUp[]; onAsk: (prompt: string) => void }) {
|
||||||
|
if (followUps.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{followUps.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`${item.type}-${item.label}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (item.type === 'question') onAsk(item.prompt);
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-[#dbe3ef] bg-white/70 px-3 py-2 text-[13px] text-[#334155] shadow-sm transition-colors hover:bg-white disabled:cursor-default disabled:opacity-60"
|
||||||
|
disabled={item.type !== 'question'}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/web/components/analysis/InsightCard.tsx
Normal file
31
apps/web/components/analysis/InsightCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { InsightCard as InsightCardData } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function InsightCard({ insight }: { insight: InsightCardData }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/60 bg-white/80 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
|
{insight.primaryValue && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-[44px] font-semibold leading-none tracking-normal text-[#0f172a]">
|
||||||
|
{insight.primaryValue.value}
|
||||||
|
{insight.primaryValue.unit ?? ''}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[13px] text-[#64748b]">{insight.primaryValue.label}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[15px] leading-7 text-[#111827]">{insight.summary}</p>
|
||||||
|
{(insight.semanticConfidence !== 'high' || insight.dataConfidence !== 'sufficient') && (
|
||||||
|
<p className="mt-3 text-[12px] text-[#64748b]">
|
||||||
|
语义置信:{confidenceLabel(insight.semanticConfidence)} · 数据充分性:{dataLabel(insight.dataConfidence)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceLabel(value: InsightCardData['semanticConfidence']) {
|
||||||
|
return value === 'high' ? '高' : value === 'medium' ? '中' : '低';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataLabel(value: InsightCardData['dataConfidence']) {
|
||||||
|
return value === 'sufficient' ? '数据充分' : value === 'partial' ? '部分数据' : '数据不足';
|
||||||
|
}
|
||||||
@@ -36,11 +36,11 @@ export function BugTab({ versionId, requirementIds, readOnly = false, versionBug
|
|||||||
const members = useMemberStore((s) => s.members);
|
const members = useMemberStore((s) => s.members);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scopedVersionBugs) fetchBugs();
|
if (!scopedVersionBugs) fetchBugs({ versionId });
|
||||||
}, [fetchBugs, scopedVersionBugs]);
|
}, [fetchBugs, scopedVersionBugs, versionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!versionTestCases) fetchTestCases();
|
if (!versionTestCases) fetchTestCases({ versionId });
|
||||||
}, [fetchTestCases, versionTestCases]);
|
}, [fetchTestCases, versionId, versionTestCases]);
|
||||||
|
|
||||||
const fallbackVersionBugs = useMemo(
|
const fallbackVersionBugs = useMemo(
|
||||||
() => bugs.filter((b) => b.versionId === versionId),
|
() => bugs.filter((b) => b.versionId === versionId),
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scopedVersionTasks) fetchTasks();
|
if (!scopedVersionTasks) fetchTasks({ versionId });
|
||||||
}, [fetchTasks, scopedVersionTasks]);
|
}, [fetchTasks, scopedVersionTasks, versionId]);
|
||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface RequirementModalProps {
|
|||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: any) => void;
|
onSubmit: (data: any) => void;
|
||||||
onOpenDrawer: (type: 'source' | 'type' | 'platform') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
||||||
@@ -45,7 +44,6 @@ export function RequirementModal({
|
|||||||
currentUserName,
|
currentUserName,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onOpenDrawer,
|
|
||||||
}: RequirementModalProps) {
|
}: RequirementModalProps) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
@@ -289,13 +287,6 @@ export function RequirementModal({
|
|||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
|
||||||
{SOURCE_TARGET_LABEL[sourceType]}
|
{SOURCE_TARGET_LABEL[sourceType]}
|
||||||
</label>
|
</label>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenDrawer('source')}
|
|
||||||
className="text-[11px] text-[var(--accent)] hover:underline"
|
|
||||||
>
|
|
||||||
管理
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -494,13 +485,6 @@ export function RequirementModal({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)]">支持端</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)]">支持端</label>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenDrawer('platform')}
|
|
||||||
className="text-[11px] text-[var(--accent)] hover:underline"
|
|
||||||
>
|
|
||||||
管理
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -538,13 +522,6 @@ export function RequirementModal({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)]">需求类型</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)]">需求类型</label>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenDrawer('type')}
|
|
||||||
className="text-[11px] text-[var(--accent)] hover:underline"
|
|
||||||
>
|
|
||||||
管理
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ export function TestCaseTab({
|
|||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scopedVersionCases) fetchTestCases();
|
if (!scopedVersionCases) fetchTestCases({ versionId });
|
||||||
}, [fetchTestCases, scopedVersionCases]);
|
}, [fetchTestCases, scopedVersionCases, versionId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scopedVersionBugs) fetchBugs();
|
if (!scopedVersionBugs) fetchBugs({ versionId });
|
||||||
}, [fetchBugs, scopedVersionBugs]);
|
}, [fetchBugs, scopedVersionBugs, versionId]);
|
||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
||||||
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
|
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,9 @@ import type { Requirement } from '@/lib/requirement';
|
|||||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||||
|
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
|
|
||||||
|
type PlanMemberCandidate = { id?: string; role?: string; name: string; username?: string | null };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plans: VersionPlan[];
|
plans: VersionPlan[];
|
||||||
@@ -35,8 +38,10 @@ interface Props {
|
|||||||
version?: VersionWithContext;
|
version?: VersionWithContext;
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
|
currentUserReference?: string;
|
||||||
planType: 'research' | 'product' | 'ui';
|
planType: 'research' | 'product' | 'ui';
|
||||||
versionMembers: { role: string; name: string }[];
|
versionMembers: PlanMemberCandidate[];
|
||||||
|
allMembers?: PlanMemberCandidate[];
|
||||||
linkedRequirements?: Requirement[];
|
linkedRequirements?: Requirement[];
|
||||||
allRequirements?: Requirement[];
|
allRequirements?: Requirement[];
|
||||||
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||||
@@ -72,7 +77,34 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
function buildPlanMemberCandidates(
|
||||||
|
versionMembers: PlanMemberCandidate[],
|
||||||
|
allMembers: PlanMemberCandidate[],
|
||||||
|
): PlanMemberCandidate[] {
|
||||||
|
const byName = new Map(allMembers.map((member) => [member.name, member]));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: PlanMemberCandidate[] = [];
|
||||||
|
|
||||||
|
const push = (member: PlanMemberCandidate) => {
|
||||||
|
const key = member.id || member.name;
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(member);
|
||||||
|
};
|
||||||
|
|
||||||
|
versionMembers.forEach((member) => {
|
||||||
|
const canonical = byName.get(member.name);
|
||||||
|
push({ ...member, id: member.id ?? canonical?.id, username: member.username ?? canonical?.username });
|
||||||
|
});
|
||||||
|
allMembers.forEach(push);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberReferenceValue(member: PlanMemberCandidate): string {
|
||||||
|
return member.username || member.id || member.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, currentUserReference, planType, versionMembers, allMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||||
@@ -81,6 +113,10 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
|
|
||||||
const typePlans = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
|
const typePlans = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
|
||||||
const totalDuration = calcTotalDuration(typePlans);
|
const totalDuration = calcTotalDuration(typePlans);
|
||||||
|
const planMemberCandidates = useMemo(
|
||||||
|
() => buildPlanMemberCandidates(versionMembers, allMembers ?? []),
|
||||||
|
[versionMembers, allMembers],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
|
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
|
||||||
@@ -113,6 +149,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
const completionState = getPlanCompletionState(plan);
|
const completionState = getPlanCompletionState(plan);
|
||||||
const canToggle = canTogglePlanChecklist(plan);
|
const canToggle = canTogglePlanChecklist(plan);
|
||||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||||
|
const ownerLabel = resolveMemberDisplayName(plan.owner, planMemberCandidates);
|
||||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||||
// 耗时用实际时间戳计算
|
// 耗时用实际时间戳计算
|
||||||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||||||
@@ -150,7 +187,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||||
@@ -276,7 +313,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={transferTo || 'all'}
|
value={transferTo || 'all'}
|
||||||
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
||||||
options={versionMembers.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
|
options={planMemberCandidates.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
|
||||||
allLabel="选择参与人员"
|
allLabel="选择参与人员"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -310,7 +347,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
versionDeadline={versionDeadline}
|
versionDeadline={versionDeadline}
|
||||||
version={version}
|
version={version}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
versionMembers={versionMembers}
|
versionMembers={planMemberCandidates}
|
||||||
linkedRequirements={linkedRequirements}
|
linkedRequirements={linkedRequirements}
|
||||||
allRequirements={allRequirements}
|
allRequirements={allRequirements}
|
||||||
transferPlanId={transferPlanId}
|
transferPlanId={transferPlanId}
|
||||||
@@ -333,6 +370,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
versionId={versionId}
|
versionId={versionId}
|
||||||
versionDeadline={versionDeadline}
|
versionDeadline={versionDeadline}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
|
currentUserReference={currentUserReference}
|
||||||
|
memberCandidates={planMemberCandidates}
|
||||||
linkedRequirements={linkedRequirements}
|
linkedRequirements={linkedRequirements}
|
||||||
allRequirements={allRequirements}
|
allRequirements={allRequirements}
|
||||||
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
||||||
@@ -412,7 +451,7 @@ function ProductUiPlanWorkspace({
|
|||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
version?: VersionWithContext;
|
version?: VersionWithContext;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
versionMembers: { role: string; name: string }[];
|
versionMembers: PlanMemberCandidate[];
|
||||||
linkedRequirements?: Requirement[];
|
linkedRequirements?: Requirement[];
|
||||||
allRequirements?: Requirement[];
|
allRequirements?: Requirement[];
|
||||||
transferPlanId: string | null;
|
transferPlanId: string | null;
|
||||||
@@ -427,6 +466,7 @@ function ProductUiPlanWorkspace({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
||||||
|
const autoStartedPlanIds = useRef<Set<string>>(new Set());
|
||||||
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
||||||
const selectedPlanIdOrFirst = selectedPlan?.id;
|
const selectedPlanIdOrFirst = selectedPlan?.id;
|
||||||
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
||||||
@@ -435,7 +475,8 @@ function ProductUiPlanWorkspace({
|
|||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
typePlans.forEach((plan) => {
|
typePlans.forEach((plan) => {
|
||||||
const { autoStarted } = getPlanRuntime(plan);
|
const { autoStarted } = getPlanRuntime(plan);
|
||||||
if (autoStarted && !plan.actualStartAt) {
|
if (autoStarted && !plan.actualStartAt && !autoStartedPlanIds.current.has(plan.id)) {
|
||||||
|
autoStartedPlanIds.current.add(plan.id);
|
||||||
onUpdate(plan.id, { status: 'in_progress' });
|
onUpdate(plan.id, { status: 'in_progress' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -470,6 +511,7 @@ function ProductUiPlanWorkspace({
|
|||||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
{typePlans.map((plan) => {
|
{typePlans.map((plan) => {
|
||||||
const { effectiveStatus } = getPlanRuntime(plan);
|
const { effectiveStatus } = getPlanRuntime(plan);
|
||||||
|
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
|
||||||
const summary = plan.type === 'research'
|
const summary = plan.type === 'research'
|
||||||
? getResearchDirectionProgressSummary(plan)
|
? getResearchDirectionProgressSummary(plan)
|
||||||
: getRequirementCoverageSummary(plan);
|
: getRequirementCoverageSummary(plan);
|
||||||
@@ -488,7 +530,7 @@ function ProductUiPlanWorkspace({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
||||||
<span className="truncate">{plan.owner}</span>
|
<span className="truncate">{ownerLabel}</span>
|
||||||
{plan.type === 'product' ? (
|
{plan.type === 'product' ? (
|
||||||
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -577,7 +619,7 @@ function ProductUiPlanDetail({
|
|||||||
planType: VersionPlan['type'];
|
planType: VersionPlan['type'];
|
||||||
version?: VersionWithContext;
|
version?: VersionWithContext;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
versionMembers: { role: string; name: string }[];
|
versionMembers: PlanMemberCandidate[];
|
||||||
linkedRequirements?: Requirement[];
|
linkedRequirements?: Requirement[];
|
||||||
allRequirements?: Requirement[];
|
allRequirements?: Requirement[];
|
||||||
transferPlanId: string | null;
|
transferPlanId: string | null;
|
||||||
@@ -595,6 +637,7 @@ function ProductUiPlanDetail({
|
|||||||
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
||||||
const completionState = getPlanCompletionState(plan);
|
const completionState = getPlanCompletionState(plan);
|
||||||
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
||||||
|
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
|
||||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||||
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
||||||
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
||||||
@@ -649,7 +692,7 @@ function ProductUiPlanDetail({
|
|||||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
||||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
|
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
|
||||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||||
@@ -738,7 +781,7 @@ function ProductUiPlanDetail({
|
|||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={transferTo || 'all'}
|
value={transferTo || 'all'}
|
||||||
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
|
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
|
||||||
options={versionMembers.filter((member) => member.name !== plan.owner).map((member) => ({ value: member.name, label: member.name }))}
|
options={versionMembers.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
|
||||||
allLabel="选择参与人员"
|
allLabel="选择参与人员"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -757,12 +800,14 @@ function ProductUiPlanDetail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, currentUserReference, memberCandidates, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||||
initial: VersionPlan | null;
|
initial: VersionPlan | null;
|
||||||
planType: 'research' | 'product' | 'ui';
|
planType: 'research' | 'product' | 'ui';
|
||||||
versionId: string;
|
versionId: string;
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
|
currentUserReference?: string;
|
||||||
|
memberCandidates: PlanMemberCandidate[];
|
||||||
linkedRequirements?: Requirement[];
|
linkedRequirements?: Requirement[];
|
||||||
allRequirements?: Requirement[];
|
allRequirements?: Requirement[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -770,7 +815,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
}) {
|
}) {
|
||||||
const now = new Date().toISOString().slice(0, 16);
|
const now = new Date().toISOString().slice(0, 16);
|
||||||
const [title, setTitle] = useState(initial?.title ?? '');
|
const [title, setTitle] = useState(initial?.title ?? '');
|
||||||
const [owner] = useState(initial?.owner ?? currentUserName);
|
const [owner] = useState(initial?.owner ?? currentUserReference ?? currentUserName);
|
||||||
|
const ownerLabel = resolveMemberDisplayName(owner, memberCandidates);
|
||||||
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
|
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
|
||||||
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
|
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
|
||||||
const [remark, setRemark] = useState(initial?.remark ?? '');
|
const [remark, setRemark] = useState(initial?.remark ?? '');
|
||||||
@@ -801,7 +847,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
versionId,
|
versionId,
|
||||||
type: planType,
|
type: planType,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
owner: owner.trim() || currentUserName,
|
owner: owner.trim() || currentUserReference || currentUserName,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
status: initial?.status ?? 'pending',
|
status: initial?.status ?? 'pending',
|
||||||
@@ -828,7 +874,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">负责人</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">负责人</label>
|
||||||
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
|
<input value={ownerLabel} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
|
||||||
</div>
|
</div>
|
||||||
{planType === 'product' && (
|
{planType === 'product' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, 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';
|
||||||
@@ -31,6 +31,17 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
const userName = user?.name ?? '';
|
const userName = user?.name ?? '';
|
||||||
const workspaceUserKey = userId || userName;
|
const workspaceUserKey = userId || userName;
|
||||||
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
|
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
|
||||||
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
const hydrateWorkspaceStoreFallback = useCallback(() => {
|
||||||
|
for (const version of allVersions) {
|
||||||
|
void fetchRequirements({ productId: version.productId, versionId: version.id });
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
void fetchBugs({ versionId: version.id });
|
||||||
|
}
|
||||||
|
}, [allVersions, fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoFetch || !workspaceUserKey.trim()) {
|
if (!autoFetch || !workspaceUserKey.trim()) {
|
||||||
setV22WorkspaceData(null);
|
setV22WorkspaceData(null);
|
||||||
@@ -61,30 +72,21 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoFetch || !workspaceUserKey.trim() || !v22WorkspaceFailed) return;
|
if (!autoFetch || !workspaceUserKey.trim() || !v22WorkspaceFailed) return;
|
||||||
void fetchPlans();
|
hydrateWorkspaceStoreFallback();
|
||||||
void fetchRequirements();
|
|
||||||
void fetchTasks();
|
|
||||||
void fetchTestCases();
|
|
||||||
void fetchBugs();
|
|
||||||
}, [
|
}, [
|
||||||
autoFetch,
|
autoFetch,
|
||||||
fetchBugs,
|
hydrateWorkspaceStoreFallback,
|
||||||
fetchPlans,
|
|
||||||
fetchRequirements,
|
|
||||||
fetchTasks,
|
|
||||||
fetchTestCases,
|
|
||||||
v22WorkspaceFailed,
|
v22WorkspaceFailed,
|
||||||
workspaceUserKey,
|
workspaceUserKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
|
||||||
|
|
||||||
const versionMap = useMemo(() => {
|
const versionMap = useMemo(() => {
|
||||||
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
|
const map = new Map<string, { id: string; name: string; productId: string; productName: string; projectName: string }>();
|
||||||
allVersions.forEach((version) => {
|
allVersions.forEach((version) => {
|
||||||
map.set(version.id, {
|
map.set(version.id, {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
name: version.name,
|
name: version.name,
|
||||||
|
productId: version.productId,
|
||||||
productName: version.productName,
|
productName: version.productName,
|
||||||
projectName: version.projectName,
|
projectName: version.projectName,
|
||||||
});
|
});
|
||||||
@@ -105,7 +107,7 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea
|
|||||||
v22Loaded: v22WorkspaceLoaded,
|
v22Loaded: v22WorkspaceLoaded,
|
||||||
v22Failed: v22WorkspaceFailed,
|
v22Failed: v22WorkspaceFailed,
|
||||||
v22Data: v22WorkspaceData,
|
v22Data: v22WorkspaceData,
|
||||||
appData: { plans, devTasks, testCases, bugs },
|
storeData: { plans, devTasks, testCases, bugs },
|
||||||
}),
|
}),
|
||||||
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
|
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
collectV22XiaobaoLatestInsights,
|
collectV22XiaobaoLatestInsights,
|
||||||
filterV22XiaobaoSummariesForVisibleVersions,
|
filterV22XiaobaoSummariesForVisibleVersions,
|
||||||
mergeV22XiaobaoLatestInsights,
|
mergeV22XiaobaoLatestInsights,
|
||||||
shouldLoadXiaobaoAppDataFallback,
|
shouldLoadXiaobaoStoreFallback,
|
||||||
type V22XiaobaoSummaryLoadState,
|
type V22XiaobaoSummaryLoadState,
|
||||||
} from '@/lib/xiaobao-v22-summary';
|
} from '@/lib/xiaobao-v22-summary';
|
||||||
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
@@ -55,12 +55,26 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
const [v22Summaries, setV22Summaries] = useState<V22XiaobaoWarningSummary[]>([]);
|
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 v22UserId = user?.name || user?.id || '';
|
||||||
const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState);
|
const shouldLoadFallback = shouldLoadXiaobaoStoreFallback(v22SummaryState);
|
||||||
const relationInsights = useMemo(() => collectV22XiaobaoLatestInsights(v22Summaries), [v22Summaries]);
|
const relationInsights = useMemo(() => collectV22XiaobaoLatestInsights(v22Summaries), [v22Summaries]);
|
||||||
const mergedInsights = useMemo(
|
const mergedInsights = useMemo(
|
||||||
() => mergeV22XiaobaoLatestInsights(relationInsights, insights),
|
() => mergeV22XiaobaoLatestInsights(relationInsights, insights),
|
||||||
[insights, relationInsights],
|
[insights, relationInsights],
|
||||||
);
|
);
|
||||||
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
const visibleVersions = useMemo(
|
||||||
|
() => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }),
|
||||||
|
[allVersions, canManage, user?.name],
|
||||||
|
);
|
||||||
|
const hydrateXiaobaoStoreFallback = useCallback(() => {
|
||||||
|
for (const version of visibleVersions) {
|
||||||
|
void fetchRequirements({ productId: version.productId, versionId: version.id });
|
||||||
|
void fetchPlans({ versionId: version.id });
|
||||||
|
void fetchTasks({ versionId: version.id });
|
||||||
|
void fetchTestCases({ versionId: version.id });
|
||||||
|
void fetchBugs({ versionId: version.id });
|
||||||
|
}
|
||||||
|
}, [fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases, visibleVersions]);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,23 +103,15 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
ignore = true;
|
ignore = true;
|
||||||
};
|
};
|
||||||
}, [canManage, v22UserId]);
|
}, [canManage, v22UserId]);
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchPlans(); }, [fetchPlans, shouldLoadFallback]);
|
useEffect(() => {
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchRequirements(); }, [fetchRequirements, shouldLoadFallback]);
|
if (shouldLoadFallback) hydrateXiaobaoStoreFallback();
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchTasks(); }, [fetchTasks, shouldLoadFallback]);
|
}, [hydrateXiaobaoStoreFallback, shouldLoadFallback]);
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchTestCases(); }, [fetchTestCases, shouldLoadFallback]);
|
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchBugs(); }, [fetchBugs, shouldLoadFallback]);
|
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchActivities(); }, [fetchActivities, shouldLoadFallback]);
|
useEffect(() => { if (shouldLoadFallback) fetchActivities(); }, [fetchActivities, shouldLoadFallback]);
|
||||||
useEffect(() => { if (shouldLoadFallback) fetchWorklogs(); }, [fetchWorklogs, shouldLoadFallback]);
|
useEffect(() => { if (shouldLoadFallback) fetchWorklogs(); }, [fetchWorklogs, shouldLoadFallback]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadRiskCache) fetchRiskData();
|
if (loadRiskCache) fetchRiskData();
|
||||||
}, [fetchRiskData, loadRiskCache]);
|
}, [fetchRiskData, loadRiskCache]);
|
||||||
|
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
|
||||||
const visibleVersions = useMemo(
|
|
||||||
() => filterXiaobaoWarningVersions(allVersions, { canManage, userName: user?.name }),
|
|
||||||
[allVersions, canManage, user?.name],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleVersionScopeMap = useMemo(() => buildVersionDataScopeMap({
|
const visibleVersionScopeMap = useMemo(() => buildVersionDataScopeMap({
|
||||||
versionIds: visibleVersions.map((version) => version.id),
|
versionIds: visibleVersions.map((version) => version.id),
|
||||||
plans,
|
plans,
|
||||||
|
|||||||
26
apps/web/lib/analysis-api.test.ts
Normal file
26
apps/web/lib/analysis-api.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { requestAnalysis } from './analysis-api';
|
||||||
|
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
|
||||||
|
|
||||||
|
test('requestAnalysis posts to /ai/analysis', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const apiBase = resolveApiBase();
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
calls.push(String(input));
|
||||||
|
return new Response(JSON.stringify(calls.length === 1 ? {} : { ok: false, code: 'NO_DATA', message: 'no rows' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
__resetApiAvailabilityForTests();
|
||||||
|
const result = await requestAnalysis({ question: '哪个部门最忙', context: { surface: 'ai_assistant' } }, ['management:view']);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.deepEqual(calls, [`${apiBase}/config/ai`, `${apiBase}/ai/analysis`]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
6
apps/web/lib/analysis-api.ts
Normal file
6
apps/web/lib/analysis-api.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise<AnalysisResponse> {
|
||||||
|
return api.post<AnalysisResponse>('/ai/analysis', { ...request, permissions });
|
||||||
|
}
|
||||||
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal file
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { toEChartsOption } from './analysis-chart-renderer';
|
||||||
|
import type { UnifiedChartSpec } from '@ftb/shared';
|
||||||
|
|
||||||
|
test('toEChartsOption renders line_area with smooth line and area gradient', () => {
|
||||||
|
const spec: UnifiedChartSpec = {
|
||||||
|
kind: 'line_area',
|
||||||
|
title: '需求完成趋势',
|
||||||
|
dataset: { source: [{ label: '2026-07-01', value: 3 }], x: 'label', y: 'value' },
|
||||||
|
encoding: {
|
||||||
|
x: { field: 'label', label: '日期' },
|
||||||
|
y: { field: 'value', label: '完成数' },
|
||||||
|
value: { field: 'value', label: '完成数' },
|
||||||
|
color: { mode: 'single' },
|
||||||
|
},
|
||||||
|
annotations: [{ type: 'peak', label: '峰值', field: 'value', value: 3 }],
|
||||||
|
stylePreset: 'apple_vision_light',
|
||||||
|
};
|
||||||
|
|
||||||
|
const option: any = toEChartsOption(spec);
|
||||||
|
|
||||||
|
assert.equal(option.series[0].type, 'line');
|
||||||
|
assert.equal(option.series[0].smooth, true);
|
||||||
|
assert.ok(option.series[0].areaStyle);
|
||||||
|
assert.equal(option.xAxis.show, true);
|
||||||
|
assert.equal(option.yAxis.splitLine.show, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toEChartsOption renders horizontal_bar with rounded bars and single color', () => {
|
||||||
|
const option: any = toEChartsOption({
|
||||||
|
kind: 'horizontal_bar',
|
||||||
|
title: '部门负载',
|
||||||
|
dataset: { source: [{ label: '研发', value: 8 }], label: 'label', value: 'value' },
|
||||||
|
encoding: {
|
||||||
|
value: { field: 'value', label: '待办数' },
|
||||||
|
color: { mode: 'single' },
|
||||||
|
},
|
||||||
|
stylePreset: 'apple_vision_light',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(option.series[0].type, 'bar');
|
||||||
|
assert.deepEqual(option.series[0].itemStyle.borderRadius, [0, 8, 8, 0]);
|
||||||
|
assert.equal(option.color.length, 1);
|
||||||
|
});
|
||||||
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { UnifiedChartSpec } from '@ftb/shared';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
const ACCENT = '#0f172a';
|
||||||
|
const MUTED = '#94a3b8';
|
||||||
|
const RISK = '#f97316';
|
||||||
|
|
||||||
|
export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
|
||||||
|
if (spec.kind === 'line_area') return lineAreaOption(spec);
|
||||||
|
if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
|
||||||
|
if (spec.kind === 'donut') return donutOption(spec);
|
||||||
|
return numberCardFallbackOption(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
|
||||||
|
const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
|
||||||
|
const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
|
||||||
|
return {
|
||||||
|
color: [ACCENT],
|
||||||
|
grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
|
||||||
|
tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
show: true,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: MUTED, fontSize: 11 },
|
||||||
|
data: spec.dataset.source.map((row) => formatCategory(row[xField])),
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
show: true,
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 7,
|
||||||
|
data: spec.dataset.source.map((row) => formatNumeric(row[yField])),
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
areaStyle: { opacity: 0.14 },
|
||||||
|
markPoint: buildMarkPoints(spec),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
|
||||||
|
const labelField = spec.dataset.label ?? 'label';
|
||||||
|
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||||||
|
const rows = spec.dataset.source.slice().reverse();
|
||||||
|
return {
|
||||||
|
color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
|
||||||
|
grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
|
||||||
|
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||||||
|
xAxis: { type: 'value', show: false },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: '#334155', fontSize: 12 },
|
||||||
|
data: rows.map((row) => formatCategory(row[labelField])),
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: rows.map((row) => formatNumeric(row[valueField])),
|
||||||
|
barWidth: 12,
|
||||||
|
itemStyle: { borderRadius: [0, 8, 8, 0] },
|
||||||
|
label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
|
||||||
|
animationDuration: 520,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function donutOption(spec: UnifiedChartSpec): EChartsOption {
|
||||||
|
const labelField = spec.dataset.label ?? 'label';
|
||||||
|
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||||||
|
return {
|
||||||
|
color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
|
||||||
|
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['62%', '82%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
label: { color: '#334155', fontSize: 11 },
|
||||||
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||||
|
data: spec.dataset.source.map((row) => ({ name: formatCategory(row[labelField]), value: formatNumeric(row[valueField]) })),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
|
||||||
|
return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarkPoints(spec: UnifiedChartSpec) {
|
||||||
|
if (!spec.annotations?.length) return undefined;
|
||||||
|
return {
|
||||||
|
symbolSize: 42,
|
||||||
|
label: { fontSize: 10 },
|
||||||
|
data: spec.annotations.map((item) => ({
|
||||||
|
type: item.type === 'peak' ? 'max' as const : undefined,
|
||||||
|
name: item.label,
|
||||||
|
value: item.value,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCategory(value: string | number | null): string {
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumeric(value: string | number | null): number {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal file
44
apps/web/lib/analysis-context-entrypoints.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
function readSource(path: string): string {
|
||||||
|
assert.equal(existsSync(path), true, `${path} should exist`);
|
||||||
|
return readFileSync(path, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('analysis context drawer is read-only and reuses the shared analysis result block', () => {
|
||||||
|
const entryButton = readSource('components/analysis/AnalysisEntryButton.tsx');
|
||||||
|
const drawer = readSource('components/analysis/AnalysisContextDrawer.tsx');
|
||||||
|
|
||||||
|
assert.match(entryButton, /export function AnalysisEntryButton/);
|
||||||
|
assert.match(entryButton, /智能分析/);
|
||||||
|
assert.match(drawer, /requestAnalysis/);
|
||||||
|
assert.match(drawer, /AnalysisResultBlock/);
|
||||||
|
assert.match(drawer, /只读取当前上下文和已有权限数据/);
|
||||||
|
assert.match(drawer, /permissions: string\[\]/);
|
||||||
|
assert.doesNotMatch(drawer, /create(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
|
||||||
|
assert.doesNotMatch(drawer, /update(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
|
||||||
|
assert.doesNotMatch(drawer, /delete(Product|Project|Version|Requirement|Plan|Task|TestCase|Bug)/);
|
||||||
|
assert.doesNotMatch(drawer, /use(Product|Requirement|VersionPlan|DevTask|TestCase|Bug)Store/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product, project, and version detail pages wire analysis drawer with scoped contexts', () => {
|
||||||
|
const productPage = readSource('app/products/[id]/page.tsx');
|
||||||
|
const projectPage = readSource('app/projects/[id]/page.tsx');
|
||||||
|
const versionPage = readSource('app/versions/[id]/page.tsx');
|
||||||
|
|
||||||
|
for (const source of [productPage, projectPage, versionPage]) {
|
||||||
|
assert.match(source, /AnalysisEntryButton/);
|
||||||
|
assert.match(source, /AnalysisContextDrawer/);
|
||||||
|
assert.match(source, /showAnalysisDrawer/);
|
||||||
|
assert.match(source, /currentPermissions/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(productPage, /surface: 'product_detail'/);
|
||||||
|
assert.match(productPage, /productId: currentProduct\.id/);
|
||||||
|
assert.match(projectPage, /surface: 'project_detail'/);
|
||||||
|
assert.match(projectPage, /projectId: project\.id/);
|
||||||
|
assert.match(versionPage, /surface: 'version_detail'/);
|
||||||
|
assert.match(versionPage, /versionId: version\.id/);
|
||||||
|
});
|
||||||
@@ -112,3 +112,20 @@ test('API availability probe retries after a transient failure', async () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('raw API requests map abort errors to a readable timeout message', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
throw new Error('signal is aborted without reason');
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => api.postRaw('/ai/decompose', {}, 1),
|
||||||
|
/请求超时/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ function getErrorMessage(body: unknown, status: number) {
|
|||||||
return `Request failed: ${status}`;
|
return `Request failed: ${status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbortLikeError(error: unknown) {
|
||||||
|
const value = error as { name?: unknown; message?: unknown };
|
||||||
|
const text = `${typeof value?.name === 'string' ? value.name : ''} ${typeof value?.message === 'string' ? value.message : String(error ?? '')}`;
|
||||||
|
return /abort|aborted|timeout|timed out/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeoutMs(timeoutMs: number) {
|
||||||
|
if (timeoutMs >= 60000) return `约 ${Math.ceil(timeoutMs / 60000)} 分钟`;
|
||||||
|
return `${Math.ceil(timeoutMs / 1000)} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
@@ -110,6 +121,11 @@ export const api = {
|
|||||||
throw new ApiRequestError(res.status, err);
|
throw new ApiRequestError(res.status, err);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortLikeError(e)) {
|
||||||
|
throw new Error(`请求超时:服务在${formatTimeoutMs(timeoutMs)}内没有返回,请稍后重试。`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(tid);
|
clearTimeout(tid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Requirement, RequirementStatus, SourceType } from './requirement';
|
|||||||
import type { TaskCategory } from './task-category';
|
import type { TaskCategory } from './task-category';
|
||||||
import type { TaskWorklog } from './task-worklog';
|
import type { TaskWorklog } from './task-worklog';
|
||||||
import type { TestCase, TestCaseStatus } from './test-case';
|
import type { TestCase, TestCaseStatus } from './test-case';
|
||||||
import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
|
import type { PlanTask, VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
|
||||||
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
|
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
|
||||||
|
|
||||||
export interface RootProject {
|
export interface RootProject {
|
||||||
@@ -117,6 +117,9 @@ interface DomainRequirementRow {
|
|||||||
creatorId?: string | null;
|
creatorId?: string | null;
|
||||||
creatorName?: string | null;
|
creatorName?: string | null;
|
||||||
creator?: { id?: string | null; name?: string | null } | null;
|
creator?: { id?: string | null; name?: string | null } | null;
|
||||||
|
productOwnerId?: string | null;
|
||||||
|
productOwnerName?: string | null;
|
||||||
|
productOwner?: { id?: string | null; name?: string | null } | null;
|
||||||
createdAt?: string | Date | null;
|
createdAt?: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +171,7 @@ interface DomainVersionPlanRow {
|
|||||||
actualStartAt?: string | Date | null;
|
actualStartAt?: string | Date | null;
|
||||||
completedAt?: string | Date | null;
|
completedAt?: string | Date | null;
|
||||||
resultUrl?: string | null;
|
resultUrl?: string | null;
|
||||||
|
tasks?: unknown;
|
||||||
requirementCoverage?: unknown;
|
requirementCoverage?: unknown;
|
||||||
logs?: unknown;
|
logs?: unknown;
|
||||||
createdAt?: string | Date | null;
|
createdAt?: string | Date | null;
|
||||||
@@ -594,6 +598,7 @@ function toRequirementPayload(data: Partial<Requirement>) {
|
|||||||
...(data.sourceTarget !== undefined && { sourceTarget: data.sourceTarget }),
|
...(data.sourceTarget !== undefined && { sourceTarget: data.sourceTarget }),
|
||||||
...(data.platforms !== undefined && { platform: data.platforms.join(',') }),
|
...(data.platforms !== undefined && { platform: data.platforms.join(',') }),
|
||||||
...(data.priority !== undefined && { priority: priorityToNumber(data.priority) }),
|
...(data.priority !== undefined && { priority: priorityToNumber(data.priority) }),
|
||||||
|
...(data.productOwner !== undefined && { productOwnerId: data.productOwner }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +618,7 @@ function normalizeRequirement(row: DomainRequirementRow): Requirement {
|
|||||||
status: toRequirementStatus(row.status),
|
status: toRequirementStatus(row.status),
|
||||||
priority: toPriority(row.priority),
|
priority: toPriority(row.priority),
|
||||||
effort: 'M',
|
effort: 'M',
|
||||||
|
productOwner: row.productOwner?.name ?? row.productOwnerName ?? row.productOwnerId ?? '',
|
||||||
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
|
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
|
||||||
createdAt: isoString(row.createdAt),
|
createdAt: isoString(row.createdAt),
|
||||||
};
|
};
|
||||||
@@ -669,6 +675,7 @@ function toVersionPlanPayload(data: Partial<VersionPlan>) {
|
|||||||
...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }),
|
...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }),
|
||||||
...(data.completedAt !== undefined && { completedAt: data.completedAt }),
|
...(data.completedAt !== undefined && { completedAt: data.completedAt }),
|
||||||
...(data.resultUrl !== undefined && { resultUrl: data.resultUrl }),
|
...(data.resultUrl !== undefined && { resultUrl: data.resultUrl }),
|
||||||
|
...(data.tasks !== undefined && { tasks: data.tasks }),
|
||||||
...(data.linkedRequirementIds !== undefined && { linkedRequirementIds: data.linkedRequirementIds }),
|
...(data.linkedRequirementIds !== undefined && { linkedRequirementIds: data.linkedRequirementIds }),
|
||||||
...(data.requirementCoverage !== undefined && { requirementCoverage: data.requirementCoverage }),
|
...(data.requirementCoverage !== undefined && { requirementCoverage: data.requirementCoverage }),
|
||||||
...(data.logs !== undefined && { logs: data.logs }),
|
...(data.logs !== undefined && { logs: data.logs }),
|
||||||
@@ -686,7 +693,7 @@ function normalizeVersionPlan(row: DomainVersionPlanRow): VersionPlan {
|
|||||||
startTime: isoString(row.expectedStartAt),
|
startTime: isoString(row.expectedStartAt),
|
||||||
endTime: isoString(row.expectedEndAt),
|
endTime: isoString(row.expectedEndAt),
|
||||||
status: toPlanStatus(row.status),
|
status: toPlanStatus(row.status),
|
||||||
tasks: [],
|
tasks: asArray<PlanTask>(row.tasks),
|
||||||
completedRequirementIds: requirementCoverage
|
completedRequirementIds: requirementCoverage
|
||||||
.filter((item) => item?.status === 'completed')
|
.filter((item) => item?.status === 'completed')
|
||||||
.map((item) => item.requirementId)
|
.map((item) => item.requirementId)
|
||||||
|
|||||||
21
apps/web/lib/governance-source.test.ts
Normal file
21
apps/web/lib/governance-source.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('governance settings is the unified dictionary entry for requirement and task types', () => {
|
||||||
|
const sidebar = readFileSync('components/layout/Sidebar.tsx', 'utf8');
|
||||||
|
const permissions = readFileSync('lib/permissions.ts', 'utf8');
|
||||||
|
const governancePage = readFileSync('app/admin/governance/page.tsx', 'utf8');
|
||||||
|
const appModule = readFileSync(join(process.cwd(), '../server/src/app.module.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(sidebar, /治理设置/);
|
||||||
|
assert.match(sidebar, /\/admin\/governance/);
|
||||||
|
assert.doesNotMatch(sidebar, /\/admin\/categories/);
|
||||||
|
assert.match(permissions, /governance:manage/);
|
||||||
|
assert.match(appModule, /GovernanceModule/);
|
||||||
|
assert.match(governancePage, /task_category/);
|
||||||
|
assert.match(governancePage, /requirement_type/);
|
||||||
|
assert.match(governancePage, /requirement_platform/);
|
||||||
|
assert.match(governancePage, /requirement_source/);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ test('member store save helper does not swallow AppData save failures', () => {
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/async function saveStored\(state: \{ departments: Department\[\]; members: Member\[\]; roles: RoleItem\[\]; passwordRule: PasswordRule \}\) \{\s+await saveServerData\('members', state\);\s+\}/,
|
/async function saveStored\(state: MemberConfigState\) \{\s+await saveServerData\('members', \{ departments: state\.departments, members: \[\], roles: state\.roles, passwordRule: state\.passwordRule \}\);\s+\}/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ test('resolves legacy Chen Shi labels to the built-in admin display name', () =>
|
|||||||
|
|
||||||
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
|
assert.equal(resolveMemberDisplayName('admin', result.members), SYSTEM_ADMIN_MEMBER_NAME);
|
||||||
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
|
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,5 +112,6 @@ test('matches legacy admin names as the built-in admin member', () => {
|
|||||||
assert.equal(isMemberReference('陈十', admin), true);
|
assert.equal(isMemberReference('陈十', admin), true);
|
||||||
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
|
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
|
||||||
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
|
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
|
||||||
|
assert.equal(isMemberReference('admin', admin), true);
|
||||||
assert.equal(isMemberReference('张三', admin), false);
|
assert.equal(isMemberReference('张三', admin), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ export function isSystemAdminMember(member: Pick<Member, 'id'>): boolean {
|
|||||||
return member.id === SYSTEM_ADMIN_MEMBER_ID;
|
return member.id === SYSTEM_ADMIN_MEMBER_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMemberDisplayName(ref: string | undefined | null, members: Member[]): string {
|
export function resolveMemberDisplayName(ref: string | undefined | null, members: Array<{ id?: string; name: string; username?: string | null }>): string {
|
||||||
if (!ref) return '-';
|
if (!ref) return '-';
|
||||||
const member = members.find((m) => m.id === ref || m.name === ref);
|
const member = members.find((m) => m.id === ref || m.name === ref || m.username === ref);
|
||||||
if (member) return member.name;
|
if (member) return member.name;
|
||||||
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
|
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
|
||||||
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
|
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
|
||||||
@@ -89,8 +89,8 @@ export function resolveMemberDisplayName(ref: string | undefined | null, members
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMemberReference(ref: string | undefined | null, member: Pick<Member, 'id' | 'name'>): boolean {
|
export function isMemberReference(ref: string | undefined | null, member: { id?: string; name: string; username?: string | null }): boolean {
|
||||||
if (!ref) return false;
|
if (!ref) return false;
|
||||||
if (ref === member.id || ref === member.name) return true;
|
if (ref === member.id || ref === member.name || ref === member.username) return true;
|
||||||
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
|
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user