From 56e0fe562ddcacbc9f9cc954c6b51482e18dd996 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 26 Jun 2026 11:01:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E4=BC=98=E5=8C=96=E6=8B=86?= =?UTF-8?q?=E8=A7=A3=E9=87=8D=E8=B7=91=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/modules/ai/ai.service.spec.ts | 43 ++- apps/server/src/modules/ai/ai.service.ts | 31 ++- .../src/modules/ai/dto/decompose.dto.ts | 6 +- .../src/modules/ai/prompts/decompose.ts | 27 +- .../dev-task/DevTaskCreateModal.tsx | 2 +- .../dev-task/DevTaskDetailDrawer.tsx | 10 +- apps/web/components/dev-task/DevTaskRow.tsx | 9 +- .../test-case/TestCaseCreateModal.tsx | 2 +- .../test-case/TestCaseDetailDrawer.tsx | 8 +- apps/web/components/test-case/TestCaseRow.tsx | 5 +- .../components/version/AiDecomposeButton.tsx | 117 ++++++--- .../version/DecomposeReportModal.tsx | 64 +++-- .../components/version/PlanDetailDrawer.tsx | 198 ++++++++++++-- apps/web/components/version/PlanTab.tsx | 247 ++++++++++++++++-- apps/web/lib/ai-decompose-dedupe.test.ts | 138 ++++++++++ apps/web/lib/ai-decompose-dedupe.ts | 125 +++++++++ apps/web/lib/ai-decompose-report.test.ts | 32 +++ apps/web/lib/ai-decompose-report.ts | 13 + apps/web/lib/ai-estimation-policy.test.ts | 25 +- apps/web/lib/ai-estimation-policy.ts | 59 +++-- apps/web/lib/dev-task.test.ts | 52 ++++ apps/web/lib/dev-task.ts | 10 +- apps/web/lib/test-case.test.ts | 43 ++- apps/web/lib/test-case.ts | 14 +- apps/web/lib/version-plan-workflow.test.ts | 59 +++++ apps/web/lib/version-plan-workflow.ts | 51 +++- apps/web/lib/version-plan.ts | 41 +++ apps/web/lib/work-effort-engine.test.ts | 9 + apps/web/lib/work-effort-engine.ts | 7 +- apps/web/stores/useTestCaseStore.ts | 1 - apps/web/stores/useVersionPlanStore.ts | 3 +- docs/agent-spec.md | 23 +- docs/architecture.md | 2 + docs/decisions.md | 33 ++- docs/workflow.md | 12 +- packages/shared/src/agent.ts | 7 +- 36 files changed, 1342 insertions(+), 186 deletions(-) create mode 100644 apps/web/lib/ai-decompose-dedupe.test.ts create mode 100644 apps/web/lib/ai-decompose-dedupe.ts create mode 100644 apps/web/lib/ai-decompose-report.test.ts create mode 100644 apps/web/lib/ai-decompose-report.ts create mode 100644 apps/web/lib/dev-task.test.ts diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 89b8b48..bce2485 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -147,9 +147,48 @@ describe('AiService', () => { expect(testCaseRequired).toContain('categoryCode'); }); - it('requires estimateHours for test case drafts', () => { + it('requires aiEstimateHours for dev task and test case drafts', () => { + const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required; const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; - expect(testCaseRequired).toContain('estimateHours'); + expect(devRequired).toContain('aiEstimateHours'); + expect(devRequired).not.toContain('estimateHours'); + expect(testCaseRequired).toContain('aiEstimateHours'); + expect(testCaseRequired).not.toContain('estimateHours'); + }); + + it('passes decomposition target into the model prompt', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_decompose', + toolInput: { + report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] }, + devTaskDrafts: [], + testCaseDrafts: [], + }, + inputTokens: 100, + outputTokens: 20, + rawModel: 'claude-test', + }); + + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('claude-test'), + } as unknown as AiGatewayService; + + const service = new AiService(gateway); + (service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001:手机号登录'); + + 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', + }); + + const userPrompt = callTool.mock.calls[0][0].userPrompt; + expect(userPrompt).toContain('本次只重新拆解开发任务'); + expect(userPrompt).toContain('testCaseDrafts 必须返回空数组'); }); }); diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 8c9cf5c..fb5f78e 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -12,6 +12,7 @@ import type { AgentDecomposeResponse, AgentDecomposeError, AgentDecomposeResult, + AgentDecomposeTarget, } from '@ftb/shared'; const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; @@ -107,7 +108,7 @@ export class AiService { }; } - const result = toolResp.toolInput as AgentDecomposeResult; + const result = this.applyTargetToResult(toolResp.toolInput as AgentDecomposeResult, req.target); if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) { return { ok: false, @@ -195,6 +196,7 @@ export class AiService { .join('\n'); const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).join('\n'); + const targetInstruction = this.buildTargetInstruction(req.target ?? 'all'); return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型 @@ -215,9 +217,36 @@ ${reqList || '(无)'} ${memberList || '(无)'} +## 本次拆解目标 + +${targetInstruction} + ## 你的任务 按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。 通过 tool 调用 submit_decompose 返回结果。`; } + + private buildTargetInstruction(target: AgentDecomposeTarget): string { + if (target === 'dev_tasks') { + return '本次只重新拆解开发任务;devTaskDrafts 正常返回,testCaseDrafts 必须返回空数组。'; + } + if (target === 'test_cases') { + return '本次只重新拆解测试用例;testCaseDrafts 正常返回,devTaskDrafts 必须返回空数组。'; + } + return '本次同时拆解开发任务和测试用例;devTaskDrafts 与 testCaseDrafts 都按实际需要返回。'; + } + + private applyTargetToResult( + result: AgentDecomposeResult, + target: AgentDecomposeTarget = 'all', + ): AgentDecomposeResult { + if (target === 'dev_tasks') { + return { ...result, testCaseDrafts: [] }; + } + if (target === 'test_cases') { + return { ...result, devTaskDrafts: [] }; + } + return result; + } } diff --git a/apps/server/src/modules/ai/dto/decompose.dto.ts b/apps/server/src/modules/ai/dto/decompose.dto.ts index 06839bc..5946036 100644 --- a/apps/server/src/modules/ai/dto/decompose.dto.ts +++ b/apps/server/src/modules/ai/dto/decompose.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsIn } from 'class-validator'; import { Type } from 'class-transformer'; export class DecomposeReqMemberDto { @@ -34,6 +34,10 @@ export class DecomposeDto { @IsString() planId!: string; + @IsOptional() + @IsIn(['all', 'dev_tasks', 'test_cases']) + target?: 'all' | 'dev_tasks' | 'test_cases'; + @IsArray() @ValidateNested({ each: true }) @Type(() => DecomposeReqRequirementDto) diff --git a/apps/server/src/modules/ai/prompts/decompose.ts b/apps/server/src/modules/ai/prompts/decompose.ts index 42947a6..bedca9d 100644 --- a/apps/server/src/modules/ai/prompts/decompose.ts +++ b/apps/server/src/modules/ai/prompts/decompose.ts @@ -38,15 +38,18 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品 - 不输出数据库 categoryId - 不输出推荐负责人(用户后续手填) -5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格) +5. AI 工时估算(字段名 aiEstimateHours,单位小时) + - aiEstimateHours 只代表 AI 对工作量的判断,不代表负责人计划排期 + - 不要输出 estimateHours、预计开始时间、预计截止时间 - 简单前端字段、文案、展示调整: 0.25-0.5h - - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h - - 拖拽排序并需要持久化接口: 1-1.5h - - 简单 CRUD 接口: 0.75-1.5h - - 数据库字段/索引调整: 0.5h - - 中等业务规则变更: 1.5-3h - - 简单功能测试用例执行: 0.25-0.5h - - API/异常/兼容性测试用例执行: 0.5-1h + - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.25-0.5h + - 拖拽排序并需要持久化接口: 0.75-1h + - 简单 CRUD 接口: 0.5-1h + - 数据库字段/索引调整: 0.25-0.5h + - 中等业务规则变更: 1-2h + - 简单功能测试用例执行: 0.1-0.3h + - API/异常测试用例执行: 0.2-0.5h + - 兼容性测试用例执行: 0.3-0.75h - 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间 6. 标题:中文动词开头,简洁 @@ -133,7 +136,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { ], }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, - estimateHours: { type: 'number' }, + aiEstimateHours: { type: 'number' }, references: { type: 'array', items: { @@ -148,7 +151,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { minItems: 1, }, }, - required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'], + required: ['title', 'categoryCode', 'priority', 'aiEstimateHours', 'references'], }, }, testCaseDrafts: { @@ -163,7 +166,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'], }, priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, - estimateHours: { type: 'number' }, + aiEstimateHours: { type: 'number' }, references: { type: 'array', items: { @@ -178,7 +181,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = { minItems: 1, }, }, - required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'], + required: ['title', 'description', 'categoryCode', 'priority', 'aiEstimateHours', 'references'], }, }, }, diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 3b7ae01..98bdc4a 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -183,7 +183,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
- +
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 94763ce..90e4f5e 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -50,6 +50,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const [showBlockInput, setShowBlockInput] = useState(false); const estimate = useMemo(() => getEstimateHours(task), [task]); + const executorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0 ? task.estimateHours : undefined; + const aiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0 ? task.aiEstimateHours : undefined; const actual = useMemo(() => getActualHours(task), [task]); const overrun = actual > estimate && estimate > 0; const requireDelay = task.status === 'todo' && needsDelayReason(task); @@ -217,8 +219,12 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel {task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}
- 预计耗时 - {formatHours(estimate)} + AI 预估 + {aiEstimate ? formatHours(aiEstimate) : '—'} +
+
+ 执行预估 + {executorEstimate ? formatHours(executorEstimate) : '待负责人填写'}
实际耗时 diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index e8e79ce..85b991d 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -34,8 +34,11 @@ function timeRangeText(task: DevTask): { text: string; tone: string } { return { text: '', tone: 'text-[var(--ink-muted)]' }; } -function hoursText(estimate: number, actual: number): { text: string; tone: string } { - if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' }; +function hoursText(task: DevTask, estimate: number, actual: number): { text: string; tone: string } { + const hasExecutorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0; + const hasAiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0; + const prefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预'; + if (actual <= 0) return { text: `${prefix} ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' }; let tone = 'text-[var(--ink-soft)]'; if (actual > estimate) tone = 'text-red-600'; else if (actual < estimate) tone = 'text-emerald-600'; @@ -46,7 +49,7 @@ export function DevTaskRow({ task, category, onClick }: Props) { const estimate = getEstimateHours(task); const actual = getActualHours(task); const range = timeRangeText(task); - const hours = hoursText(estimate, actual); + const hours = hoursText(task, estimate, actual); return (
- + c.id === tc.categoryId); const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id); const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status]; - const estimateHours = getTestCaseEstimateHours(tc); + const executorEstimateHours = typeof tc.estimateHours === 'number' && tc.estimateHours > 0 ? tc.estimateHours : undefined; + const aiEstimateHours = typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0 ? tc.aiEstimateHours : undefined; const actualHours = getTestCaseActualHours(tc); const [failReason, setFailReason] = useState(''); @@ -159,7 +160,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
优先级:{tc.priority}
任务类型:
负责人:{tc.assigneeId || '-'}
-
预估耗时:{formatWorkHours(estimateHours)}
+
AI 预估:{aiEstimateHours ? formatWorkHours(aiEstimateHours) : '—'}
+
执行预估:{executorEstimateHours ? formatWorkHours(executorEstimateHours) : '待负责人填写'}
创建人:{tc.createdBy}
创建日期:{tc.createdAt.slice(0, 10)}
{tc.startedAt &&
开始执行:{formatDateTime(tc.startedAt)}
} diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx index 7271893..9ce463f 100644 --- a/apps/web/components/test-case/TestCaseRow.tsx +++ b/apps/web/components/test-case/TestCaseRow.tsx @@ -25,6 +25,9 @@ const PRIORITY_DOT: Record = { function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) { const estimateHours = getTestCaseEstimateHours(testCase); const actualHours = getTestCaseActualHours(testCase); + const hasExecutorEstimate = typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0; + const hasAiEstimate = typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0; + const estimatePrefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预'; return (
@@ -40,7 +43,7 @@ function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
- {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`} + {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`} {bugCount > 0 && ( {bugCount} Bug diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx index 5973ad4..0d9c6e1 100644 --- a/apps/web/components/version/AiDecomposeButton.tsx +++ b/apps/web/components/version/AiDecomposeButton.tsx @@ -7,9 +7,14 @@ import type { VersionWithContext } from '@/lib/derive'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useDevTaskStore } from '@/stores/useDevTaskStore'; +import { useTestCaseStore } from '@/stores/useTestCaseStore'; +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { api } from '@/lib/api'; +import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe'; import { DecomposeReportModal } from './DecomposeReportModal'; import type { + AgentDecomposeTarget, AgentDecomposeRequest, AgentDecomposeResponse, AgentDecomposeError, @@ -33,12 +38,18 @@ function formatElapsed(sec: number): string { export function AiDecomposeButton({ plan, version }: Props) { const { updatePlan } = useVersionPlanStore(); const { requirements } = useRequirementStore(); + const devTasks = useDevTaskStore((s) => s.tasks); + const testCases = useTestCaseStore((s) => s.testCases); + const categories = useTaskCategoryStore((s) => s.categories); const user = useAuthStore((s) => s.user); - const [loading, setLoading] = useState(false); + const [activeTarget, setActiveTarget] = useState(null); const [result, setResult] = useState(null); + const [resultTarget, setResultTarget] = useState('all'); + const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 }); const [tick, setTick] = useState(0); const startedAtRef = useRef(null); + const loading = activeTarget !== null; // plan 上的状态(持久化在 localStorage) const persistStatus = plan.aiDecomposeStatus; @@ -78,14 +89,20 @@ export function AiDecomposeButton({ plan, version }: Props) { description: r.description, })); - const handleClick = async () => { + const targetText = (target: AgentDecomposeTarget) => { + if (target === 'dev_tasks') return '开发任务'; + if (target === 'test_cases') return '测试用例'; + return '任务和用例'; + }; + + const handleClick = async (target: AgentDecomposeTarget) => { if (loading) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 if (persistStatus === 'in_progress' && !isStaleInProgress) return; startedAtRef.current = Date.now(); setTick(Date.now()); - setLoading(true); + setActiveTarget(target); updatePlan(plan.id, { aiDecomposeStatus: 'in_progress', @@ -105,6 +122,7 @@ export function AiDecomposeButton({ plan, version }: Props) { members, versionId: version.id, planId: plan.id, + target, }; try { @@ -119,7 +137,19 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeError: resp.error, }); } else { - setResult(resp); + const requirementIdSet = new Set(linkedReqs.map((req) => req.id)); + const deduped = filterDuplicateDecomposeDrafts(resp.result, { + existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), + existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), + categories, + requirements: linkedReqs, + }); + setResult({ ...resp, result: deduped.result }); + setDedupeSummary({ + removedDevTaskCount: deduped.removedDevTaskCount, + removedTestCaseCount: deduped.removedTestCaseCount, + }); + setResultTarget(target); updatePlan(plan.id, { aiDecomposeStatus: 'completed', aiDecomposeError: undefined, @@ -132,7 +162,7 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeError: msg, }); } finally { - setLoading(false); + setActiveTarget(null); startedAtRef.current = null; } }; @@ -143,39 +173,48 @@ export function AiDecomposeButton({ plan, version }: Props) { return ( <> - + + {(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => { + const showSpinner = isInProgress && (!activeTarget || activeTarget === target); + const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`; + return ( + + ); + })} + {/* 错误信息:在按钮旁悬浮显示 */} {isError && persistError && ( @@ -198,6 +237,8 @@ export function AiDecomposeButton({ plan, version }: Props) { version={version} plan={plan} requirements={linkedReqs} + target={resultTarget} + dedupeSummary={dedupeSummary} onClose={() => setResult(null)} /> )} diff --git a/apps/web/components/version/DecomposeReportModal.tsx b/apps/web/components/version/DecomposeReportModal.tsx index c6a3b16..3eac1ec 100644 --- a/apps/web/components/version/DecomposeReportModal.tsx +++ b/apps/web/components/version/DecomposeReportModal.tsx @@ -9,9 +9,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category'; -import { addWorkHours } from '@/lib/work-hours'; -import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; +import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy'; +import { formatReportRequirementLabel } from '@/lib/ai-decompose-report'; import type { + AgentDecomposeTarget, AgentDecomposeResponse, AgentDevTaskDraft, AgentTestCaseDraft, @@ -22,16 +23,22 @@ interface Props { version: VersionWithContext; plan: VersionPlan; requirements: Array<{ id: string; code: string; title: string; description?: string }>; + target: AgentDecomposeTarget; + dedupeSummary?: { removedDevTaskCount: number; removedTestCaseCount: number }; onClose: () => void; } -export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) { +export function DecomposeReportModal({ result, version, plan, requirements, target, dedupeSummary, onClose }: Props) { const { createTask } = useDevTaskStore(); const { createTestCase } = useTestCaseStore(); const { categories } = useTaskCategoryStore(); const user = useAuthStore((s) => s.user); const { result: data, meta } = result; + const showDevDrafts = target !== 'test_cases'; + const showTestDrafts = target !== 'dev_tasks'; + const filteredDuplicateCount = + (dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0); const [selectedDevIdx, setSelectedDevIdx] = useState>( new Set(data.devTaskDrafts.map((_, i) => i)), @@ -90,9 +97,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl const reqRef = refs.find((r) => r.type === 'requirement'); const requirementId = reqRef?.id ?? requirements[0]?.id ?? ''; const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); - const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours); - const startISO = new Date().toISOString(); - const endISO = addWorkHours(startISO, estimateHours); + const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); createTask({ requirementId, @@ -102,9 +107,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl assigneeId: '', reviewerId: undefined, priority: draft.priority, - expectedStartAt: startISO, - expectedEndAt: endISO, - estimateHours, + expectedStartAt: '', + expectedEndAt: '', + estimateHours: undefined, + aiEstimateHours, actualStartAt: undefined, actualEndAt: undefined, status: 'todo', @@ -129,7 +135,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl const refs = normalizeRefs(draft.references); const reqRef = refs.find((r) => r.type === 'requirement'); const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); - const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours); + const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours); createTestCase({ versionId: version.id, @@ -138,7 +144,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl description: draft.description, categoryId, priority: draft.priority, - estimateHours, + estimateHours: undefined, + aiEstimateHours, assigneeId: undefined, references: refs, aiDraft: true, @@ -155,6 +162,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl setTimeout(() => onClose(), 1500); }; + const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0); + return (
+ {filteredDuplicateCount > 0 && ( +
+ 已自动过滤 {dedupeSummary?.removedDevTaskCount ?? 0} 个重复开发任务、{dedupeSummary?.removedTestCaseCount ?? 0} 个重复测试用例。 +
+ )} + {/* 对账报告 */}

对账报告

@@ -192,8 +207,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
    {data.report.matched.map((m, i) => ( -
  • - {m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务 +
  • + + {formatReportRequirementLabel(m.reqId, requirements)} + + ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
  • ))}
@@ -210,7 +231,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl 以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:

    - {data.report.reqOnly.map((id, i) =>
  • {id}
  • )} + {data.report.reqOnly.map((id, i) => { + const label = formatReportRequirementLabel(id, requirements); + return
  • {label}
  • ; + })}
)} @@ -248,6 +272,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl {/* DevTask 草案 */} + {showDevDrafts && (

开发任务草案({data.devTaskDrafts.length}) @@ -277,7 +302,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl {d.priority} - {clampDevEstimateHours(d.categoryCode, d.estimateHours)}h + AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h

{d.description && ( @@ -299,8 +324,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
)} + )} {/* TestCase 草案 */} + {showTestDrafts && (

测试用例草案({data.testCaseDrafts.length}) @@ -330,7 +357,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl {d.priority} - {clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h + AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
@@ -352,12 +379,13 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
               
             )}
           

+ )} {/* Footer */}
- 已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例 + 已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
@@ -198,6 +274,20 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)} + {plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && ( +
+
+ {PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]} +
+ {plan.reviewResult === 'failed' && ( +
+ {getFailureLabels(plan.reviewFailureTypes).length > 0 &&
类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}
} + {plan.reviewFailureReason &&
原因:{plan.reviewFailureReason}
} +
+ )} +
+ )} + {plan.remark && (
备注
@@ -223,22 +313,90 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { {/* Complete with result */} {showComplete && (
-
提交成果
- setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> -
- - -
- {resultType === 'link' ? ( - setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> +
{getSubmitActionLabel(plan)}
+ {isProductReviewPlan ? ( + <> +
+ {(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => ( + + ))} +
+ {reviewResult === 'failed' && ( + <> +
+ {PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => ( + + ))} +
+