feat(ai): 优化拆解重跑与结果展示
This commit is contained in:
@@ -147,9 +147,48 @@ describe('AiService', () => {
|
|||||||
expect(testCaseRequired).toContain('categoryCode');
|
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;
|
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 必须返回空数组');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
AgentDecomposeResponse,
|
AgentDecomposeResponse,
|
||||||
AgentDecomposeError,
|
AgentDecomposeError,
|
||||||
AgentDecomposeResult,
|
AgentDecomposeResult,
|
||||||
|
AgentDecomposeTarget,
|
||||||
} 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;
|
||||||
@@ -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)) {
|
if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -195,6 +196,7 @@ export class AiService {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).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' : ''}## 产品方案原型
|
return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型
|
||||||
|
|
||||||
@@ -215,9 +217,36 @@ ${reqList || '(无)'}
|
|||||||
|
|
||||||
${memberList || '(无)'}
|
${memberList || '(无)'}
|
||||||
|
|
||||||
|
## 本次拆解目标
|
||||||
|
|
||||||
|
${targetInstruction}
|
||||||
|
|
||||||
## 你的任务
|
## 你的任务
|
||||||
|
|
||||||
按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。
|
按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。
|
||||||
通过 tool 调用 submit_decompose 返回结果。`;
|
通过 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class DecomposeReqMemberDto {
|
export class DecomposeReqMemberDto {
|
||||||
@@ -34,6 +34,10 @@ export class DecomposeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
planId!: string;
|
planId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['all', 'dev_tasks', 'test_cases'])
|
||||||
|
target?: 'all' | 'dev_tasks' | 'test_cases';
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => DecomposeReqRequirementDto)
|
@Type(() => DecomposeReqRequirementDto)
|
||||||
|
|||||||
@@ -38,15 +38,18 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
|||||||
- 不输出数据库 categoryId
|
- 不输出数据库 categoryId
|
||||||
- 不输出推荐负责人(用户后续手填)
|
- 不输出推荐负责人(用户后续手填)
|
||||||
|
|
||||||
5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
|
5. AI 工时估算(字段名 aiEstimateHours,单位小时)
|
||||||
|
- aiEstimateHours 只代表 AI 对工作量的判断,不代表负责人计划排期
|
||||||
|
- 不要输出 estimateHours、预计开始时间、预计截止时间
|
||||||
- 简单前端字段、文案、展示调整: 0.25-0.5h
|
- 简单前端字段、文案、展示调整: 0.25-0.5h
|
||||||
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
|
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.25-0.5h
|
||||||
- 拖拽排序并需要持久化接口: 1-1.5h
|
- 拖拽排序并需要持久化接口: 0.75-1h
|
||||||
- 简单 CRUD 接口: 0.75-1.5h
|
- 简单 CRUD 接口: 0.5-1h
|
||||||
- 数据库字段/索引调整: 0.5h
|
- 数据库字段/索引调整: 0.25-0.5h
|
||||||
- 中等业务规则变更: 1.5-3h
|
- 中等业务规则变更: 1-2h
|
||||||
- 简单功能测试用例执行: 0.25-0.5h
|
- 简单功能测试用例执行: 0.1-0.3h
|
||||||
- API/异常/兼容性测试用例执行: 0.5-1h
|
- API/异常测试用例执行: 0.2-0.5h
|
||||||
|
- 兼容性测试用例执行: 0.3-0.75h
|
||||||
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
|
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
|
||||||
|
|
||||||
6. 标题:中文动词开头,简洁
|
6. 标题:中文动词开头,简洁
|
||||||
@@ -133,7 +136,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||||
estimateHours: { type: 'number' },
|
aiEstimateHours: { type: 'number' },
|
||||||
references: {
|
references: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -148,7 +151,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
minItems: 1,
|
minItems: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'],
|
required: ['title', 'categoryCode', 'priority', 'aiEstimateHours', 'references'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
testCaseDrafts: {
|
testCaseDrafts: {
|
||||||
@@ -163,7 +166,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
|
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
|
||||||
},
|
},
|
||||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||||
estimateHours: { type: 'number' },
|
aiEstimateHours: { type: 'number' },
|
||||||
references: {
|
references: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -178,7 +181,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
minItems: 1,
|
minItems: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
|
required: ['title', 'description', 'categoryCode', 'priority', 'aiEstimateHours', 'references'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计工时(自动)</label>
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">执行预估工时(自动)</label>
|
||||||
<div className="h-9 flex items-center px-3 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] text-[13px] text-[var(--ink-soft)] tabular-nums">
|
<div className="h-9 flex items-center px-3 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] text-[13px] text-[var(--ink-soft)] tabular-nums">
|
||||||
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
|
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
const [showBlockInput, setShowBlockInput] = useState(false);
|
const [showBlockInput, setShowBlockInput] = useState(false);
|
||||||
|
|
||||||
const estimate = useMemo(() => getEstimateHours(task), [task]);
|
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 actual = useMemo(() => getActualHours(task), [task]);
|
||||||
const overrun = actual > estimate && estimate > 0;
|
const overrun = actual > estimate && estimate > 0;
|
||||||
const requireDelay = task.status === 'todo' && needsDelayReason(task);
|
const requireDelay = task.status === 'todo' && needsDelayReason(task);
|
||||||
@@ -217,8 +219,12 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}</span>
|
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] text-[var(--ink-muted)]">预计耗时</span>
|
<span className="text-[10px] text-[var(--ink-muted)]">AI 预估</span>
|
||||||
<span className="text-[var(--ink)] font-medium tabular-nums">{formatHours(estimate)}</span>
|
<span className="text-[var(--ink)] font-medium tabular-nums">{aiEstimate ? formatHours(aiEstimate) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">执行预估</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{executorEstimate ? formatHours(executorEstimate) : '待负责人填写'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] text-[var(--ink-muted)]">实际耗时</span>
|
<span className="text-[10px] text-[var(--ink-muted)]">实际耗时</span>
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ function timeRangeText(task: DevTask): { text: string; tone: string } {
|
|||||||
return { text: '', tone: 'text-[var(--ink-muted)]' };
|
return { text: '', tone: 'text-[var(--ink-muted)]' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursText(estimate: number, actual: number): { text: string; tone: string } {
|
function hoursText(task: DevTask, estimate: number, actual: number): { text: string; tone: string } {
|
||||||
if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
|
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)]';
|
let tone = 'text-[var(--ink-soft)]';
|
||||||
if (actual > estimate) tone = 'text-red-600';
|
if (actual > estimate) tone = 'text-red-600';
|
||||||
else if (actual < estimate) tone = 'text-emerald-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 estimate = getEstimateHours(task);
|
||||||
const actual = getActualHours(task);
|
const actual = getActualHours(task);
|
||||||
const range = timeRangeText(task);
|
const range = timeRangeText(task);
|
||||||
const hours = hoursText(estimate, actual);
|
const hours = hoursText(task, estimate, actual);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预估耗时 *</label>
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">执行预估耗时 *</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.25"
|
min="0.25"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
|||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
||||||
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
|
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, getTestCaseActualHours } from '@/lib/test-case';
|
||||||
import { formatWorkHours } from '@/lib/work-hours';
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
@@ -39,7 +39,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
const category = categories.find((c) => c.id === tc.categoryId);
|
const category = categories.find((c) => c.id === tc.categoryId);
|
||||||
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
|
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
|
||||||
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
|
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 actualHours = getTestCaseActualHours(tc);
|
||||||
|
|
||||||
const [failReason, setFailReason] = useState('');
|
const [failReason, setFailReason] = useState('');
|
||||||
@@ -159,7 +160,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
||||||
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]">任务类型:</span><CategoryChip category={category} /></div>
|
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]">任务类型:</span><CategoryChip category={category} /></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
|
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">预估耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
|
<div><span className="text-[var(--ink-muted)]">AI 预估:</span><span className="text-[var(--ink)] font-medium">{aiEstimateHours ? formatWorkHours(aiEstimateHours) : '—'}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">执行预估:</span><span className="text-[var(--ink)] font-medium">{executorEstimateHours ? formatWorkHours(executorEstimateHours) : '待负责人填写'}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">创建人:</span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
|
<div><span className="text-[var(--ink-muted)]">创建人:</span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">创建日期:</span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
|
<div><span className="text-[var(--ink-muted)]">创建日期:</span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
|
||||||
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">开始执行:</span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
|
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">开始执行:</span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
|
function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
|
||||||
const estimateHours = getTestCaseEstimateHours(testCase);
|
const estimateHours = getTestCaseEstimateHours(testCase);
|
||||||
const actualHours = getTestCaseActualHours(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 (
|
return (
|
||||||
<div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}>
|
<div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}>
|
||||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
||||||
@@ -40,7 +43,7 @@ function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<TestCaseStatusBadge status={testCase.status} />
|
<TestCaseStatusBadge status={testCase.status} />
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
|
||||||
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
|
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `${estimatePrefix} ${formatWorkHours(estimateHours)}`}
|
||||||
</span>
|
</span>
|
||||||
{bugCount > 0 && (
|
{bugCount > 0 && (
|
||||||
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
|
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import type { VersionWithContext } from '@/lib/derive';
|
|||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
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 { api } from '@/lib/api';
|
||||||
|
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
|
||||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||||
import type {
|
import type {
|
||||||
|
AgentDecomposeTarget,
|
||||||
AgentDecomposeRequest,
|
AgentDecomposeRequest,
|
||||||
AgentDecomposeResponse,
|
AgentDecomposeResponse,
|
||||||
AgentDecomposeError,
|
AgentDecomposeError,
|
||||||
@@ -33,12 +38,18 @@ function formatElapsed(sec: number): string {
|
|||||||
export function AiDecomposeButton({ plan, version }: Props) {
|
export function AiDecomposeButton({ plan, version }: Props) {
|
||||||
const { updatePlan } = useVersionPlanStore();
|
const { updatePlan } = useVersionPlanStore();
|
||||||
const { requirements } = useRequirementStore();
|
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 user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
|
||||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||||
|
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
|
||||||
|
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
const startedAtRef = useRef<number | null>(null);
|
const startedAtRef = useRef<number | null>(null);
|
||||||
|
const loading = activeTarget !== null;
|
||||||
|
|
||||||
// plan 上的状态(持久化在 localStorage)
|
// plan 上的状态(持久化在 localStorage)
|
||||||
const persistStatus = plan.aiDecomposeStatus;
|
const persistStatus = plan.aiDecomposeStatus;
|
||||||
@@ -78,14 +89,20 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
description: r.description,
|
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;
|
if (loading) return;
|
||||||
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
|
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
|
||||||
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
|
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
|
||||||
|
|
||||||
startedAtRef.current = Date.now();
|
startedAtRef.current = Date.now();
|
||||||
setTick(Date.now());
|
setTick(Date.now());
|
||||||
setLoading(true);
|
setActiveTarget(target);
|
||||||
|
|
||||||
updatePlan(plan.id, {
|
updatePlan(plan.id, {
|
||||||
aiDecomposeStatus: 'in_progress',
|
aiDecomposeStatus: 'in_progress',
|
||||||
@@ -105,6 +122,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
members,
|
members,
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
|
target,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +137,19 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
aiDecomposeError: resp.error,
|
aiDecomposeError: resp.error,
|
||||||
});
|
});
|
||||||
} else {
|
} 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, {
|
updatePlan(plan.id, {
|
||||||
aiDecomposeStatus: 'completed',
|
aiDecomposeStatus: 'completed',
|
||||||
aiDecomposeError: undefined,
|
aiDecomposeError: undefined,
|
||||||
@@ -132,7 +162,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
aiDecomposeError: msg,
|
aiDecomposeError: msg,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setActiveTarget(null);
|
||||||
startedAtRef.current = null;
|
startedAtRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,39 +173,48 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<span className="inline-flex items-center gap-1.5">
|
||||||
onClick={handleClick}
|
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
|
||||||
disabled={isInProgress}
|
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
|
||||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
|
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
|
||||||
isError
|
return (
|
||||||
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
<button
|
||||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
key={target}
|
||||||
}`}
|
onClick={() => handleClick(target)}
|
||||||
title={
|
disabled={isInProgress}
|
||||||
isError
|
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
|
||||||
? `上次失败:${persistError || '未知错误'}(点击重试)`
|
isError
|
||||||
: wasCompleted
|
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
? '此前已拆解过,再次点击会重新拆解'
|
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||||
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
|
}`}
|
||||||
}
|
title={
|
||||||
>
|
isError
|
||||||
{isInProgress ? (
|
? `上次失败:${persistError || '未知错误'}(点击重试${targetText(target)})`
|
||||||
<>
|
: wasCompleted
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
? `此前已拆解过,再次点击会重新拆解${targetText(target)}`
|
||||||
拆解中 {formatElapsed(elapsedSec)}
|
: `使用 AI 把原型 + 关联需求拆解成${targetText(target)}`
|
||||||
</>
|
}
|
||||||
) : isError ? (
|
>
|
||||||
<>
|
{showSpinner ? (
|
||||||
<RotateCw className="h-3 w-3" />
|
<>
|
||||||
重试
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
</>
|
拆解中 {formatElapsed(elapsedSec)}
|
||||||
) : (
|
</>
|
||||||
<>
|
) : isError ? (
|
||||||
<Sparkles className="h-3 w-3" />
|
<>
|
||||||
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
|
<RotateCw className="h-3 w-3" />
|
||||||
</>
|
重试{targetText(target)}
|
||||||
)}
|
</>
|
||||||
</button>
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* 错误信息:在按钮旁悬浮显示 */}
|
{/* 错误信息:在按钮旁悬浮显示 */}
|
||||||
{isError && persistError && (
|
{isError && persistError && (
|
||||||
@@ -198,6 +237,8 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
version={version}
|
version={version}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
requirements={linkedReqs}
|
requirements={linkedReqs}
|
||||||
|
target={resultTarget}
|
||||||
|
dedupeSummary={dedupeSummary}
|
||||||
onClose={() => setResult(null)}
|
onClose={() => setResult(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
|||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||||
import { addWorkHours } from '@/lib/work-hours';
|
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||||||
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
||||||
import type {
|
import type {
|
||||||
|
AgentDecomposeTarget,
|
||||||
AgentDecomposeResponse,
|
AgentDecomposeResponse,
|
||||||
AgentDevTaskDraft,
|
AgentDevTaskDraft,
|
||||||
AgentTestCaseDraft,
|
AgentTestCaseDraft,
|
||||||
@@ -22,16 +23,22 @@ interface Props {
|
|||||||
version: VersionWithContext;
|
version: VersionWithContext;
|
||||||
plan: VersionPlan;
|
plan: VersionPlan;
|
||||||
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
|
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
|
||||||
|
target: AgentDecomposeTarget;
|
||||||
|
dedupeSummary?: { removedDevTaskCount: number; removedTestCaseCount: number };
|
||||||
onClose: () => void;
|
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 { createTask } = useDevTaskStore();
|
||||||
const { createTestCase } = useTestCaseStore();
|
const { createTestCase } = useTestCaseStore();
|
||||||
const { categories } = useTaskCategoryStore();
|
const { categories } = useTaskCategoryStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const { result: data, meta } = result;
|
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<Set<number>>(
|
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||||
new Set(data.devTaskDrafts.map((_, i) => i)),
|
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 reqRef = refs.find((r) => r.type === 'requirement');
|
||||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||||
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
|
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||||
const startISO = new Date().toISOString();
|
|
||||||
const endISO = addWorkHours(startISO, estimateHours);
|
|
||||||
|
|
||||||
createTask({
|
createTask({
|
||||||
requirementId,
|
requirementId,
|
||||||
@@ -102,9 +107,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
assigneeId: '',
|
assigneeId: '',
|
||||||
reviewerId: undefined,
|
reviewerId: undefined,
|
||||||
priority: draft.priority,
|
priority: draft.priority,
|
||||||
expectedStartAt: startISO,
|
expectedStartAt: '',
|
||||||
expectedEndAt: endISO,
|
expectedEndAt: '',
|
||||||
estimateHours,
|
estimateHours: undefined,
|
||||||
|
aiEstimateHours,
|
||||||
actualStartAt: undefined,
|
actualStartAt: undefined,
|
||||||
actualEndAt: undefined,
|
actualEndAt: undefined,
|
||||||
status: 'todo',
|
status: 'todo',
|
||||||
@@ -129,7 +135,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
const refs = normalizeRefs(draft.references);
|
const refs = normalizeRefs(draft.references);
|
||||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||||
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
|
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||||
|
|
||||||
createTestCase({
|
createTestCase({
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
@@ -138,7 +144,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
description: draft.description,
|
description: draft.description,
|
||||||
categoryId,
|
categoryId,
|
||||||
priority: draft.priority,
|
priority: draft.priority,
|
||||||
estimateHours,
|
estimateHours: undefined,
|
||||||
|
aiEstimateHours,
|
||||||
assigneeId: undefined,
|
assigneeId: undefined,
|
||||||
references: refs,
|
references: refs,
|
||||||
aiDraft: true,
|
aiDraft: true,
|
||||||
@@ -155,6 +162,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
setTimeout(() => onClose(), 1500);
|
setTimeout(() => onClose(), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -178,6 +187,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||||
|
{filteredDuplicateCount > 0 && (
|
||||||
|
<div className="rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-[12px] text-purple-700">
|
||||||
|
已自动过滤 {dedupeSummary?.removedDevTaskCount ?? 0} 个重复开发任务、{dedupeSummary?.removedTestCaseCount ?? 0} 个重复测试用例。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 对账报告 */}
|
{/* 对账报告 */}
|
||||||
<section>
|
<section>
|
||||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">对账报告</h4>
|
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">对账报告</h4>
|
||||||
@@ -192,8 +207,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
</div>
|
</div>
|
||||||
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
|
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
|
||||||
{data.report.matched.map((m, i) => (
|
{data.report.matched.map((m, i) => (
|
||||||
<li key={i}>
|
<li key={i} className="flex flex-wrap items-center gap-1">
|
||||||
{m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
|
<span
|
||||||
|
className="max-w-[280px] truncate font-medium"
|
||||||
|
title={formatReportRequirementLabel(m.reqId, requirements)}
|
||||||
|
>
|
||||||
|
{formatReportRequirementLabel(m.reqId, requirements)}
|
||||||
|
</span>
|
||||||
|
<span>↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -210,7 +231,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:
|
以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
|
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
|
||||||
{data.report.reqOnly.map((id, i) => <li key={i}>{id}</li>)}
|
{data.report.reqOnly.map((id, i) => {
|
||||||
|
const label = formatReportRequirementLabel(id, requirements);
|
||||||
|
return <li key={i} className="max-w-[360px] truncate" title={label}>{label}</li>;
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -248,6 +272,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* DevTask 草案 */}
|
{/* DevTask 草案 */}
|
||||||
|
{showDevDrafts && (
|
||||||
<section>
|
<section>
|
||||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||||
开发任务草案({data.devTaskDrafts.length})
|
开发任务草案({data.devTaskDrafts.length})
|
||||||
@@ -277,7 +302,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
{d.priority}
|
{d.priority}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||||
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
|
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{d.description && (
|
{d.description && (
|
||||||
@@ -299,8 +324,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TestCase 草案 */}
|
{/* TestCase 草案 */}
|
||||||
|
{showTestDrafts && (
|
||||||
<section>
|
<section>
|
||||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||||
测试用例草案({data.testCaseDrafts.length})
|
测试用例草案({data.testCaseDrafts.length})
|
||||||
@@ -330,7 +357,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
{d.priority}
|
{d.priority}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||||
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
|
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
||||||
@@ -352,12 +379,13 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
||||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||||
已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例
|
已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -368,7 +396,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdopt}
|
onClick={handleAdopt}
|
||||||
disabled={submitting || submitted || (selectedDevIdx.size === 0 && selectedTcIdx.size === 0)}
|
disabled={submitting || submitted || selectedVisibleCount === 0}
|
||||||
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
|
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'luc
|
|||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
import {
|
||||||
|
calcPlanProgress,
|
||||||
|
calcLinkedReqProgress,
|
||||||
|
PRODUCT_PLAN_KIND_LABEL,
|
||||||
|
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||||
|
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||||
|
} from '@/lib/version-plan';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
import type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
|
||||||
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';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -20,6 +27,24 @@ const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-6
|
|||||||
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
||||||
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
||||||
|
|
||||||
|
function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
|
||||||
|
return plan.productPlanKind ?? 'design';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmitActionLabel(plan: VersionPlan): string {
|
||||||
|
if (plan.type === 'product') {
|
||||||
|
return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
|
||||||
|
}
|
||||||
|
return '提交成果';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||||||
|
if (!types?.length) return [];
|
||||||
|
return types
|
||||||
|
.map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||||
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
@@ -31,6 +56,10 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
const [resultUrl, setResultUrl] = useState('');
|
const [resultUrl, setResultUrl] = useState('');
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [fileData, setFileData] = useState('');
|
const [fileData, setFileData] = useState('');
|
||||||
|
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
|
||||||
|
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
|
||||||
|
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
|
||||||
|
const [reviewFailureReason, setReviewFailureReason] = useState('');
|
||||||
const [showComplete, setShowComplete] = useState(false);
|
const [showComplete, setShowComplete] = useState(false);
|
||||||
|
|
||||||
const plan = plans.find((p) => p.id === planId);
|
const plan = plans.find((p) => p.id === planId);
|
||||||
@@ -42,6 +71,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||||||
const canToggle = canTogglePlanChecklist(plan);
|
const canToggle = canTogglePlanChecklist(plan);
|
||||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||||
|
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||||
|
const isProductDesignPlan = productPlanKind === 'design';
|
||||||
|
const isProductReviewPlan = productPlanKind === 'review';
|
||||||
|
|
||||||
const handleToggleTask = (task: PlanTask) => {
|
const handleToggleTask = (task: PlanTask) => {
|
||||||
if (!canToggle) return;
|
if (!canToggle) return;
|
||||||
@@ -66,11 +98,49 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
||||||
|
const next = new Set(reviewFailureTypes);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
setReviewFailureTypes(next);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmitResult = () => {
|
const handleSubmitResult = () => {
|
||||||
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
let payload: PlanResultPayload | null = null;
|
||||||
const title = resultTitle.trim();
|
|
||||||
if (!url || !title) return;
|
if (isProductReviewPlan) {
|
||||||
const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
payload = {
|
||||||
|
productPlanKind: 'review',
|
||||||
|
reviewResult,
|
||||||
|
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
|
||||||
|
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
|
||||||
|
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
|
||||||
|
};
|
||||||
|
} else if (isProductDesignPlan) {
|
||||||
|
const title = resultTitle.trim();
|
||||||
|
const url = resultUrl.trim();
|
||||||
|
if (!url || !title || !prototypeReviewConfirmed) return;
|
||||||
|
payload = {
|
||||||
|
productPlanKind: 'design',
|
||||||
|
resultType: 'link',
|
||||||
|
resultTitle: title,
|
||||||
|
resultUrl: url,
|
||||||
|
prototypeReviewConfirmed,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
||||||
|
const title = resultTitle.trim();
|
||||||
|
if (!url || !title) return;
|
||||||
|
payload = {
|
||||||
|
resultType,
|
||||||
|
resultTitle: title,
|
||||||
|
resultUrl: url,
|
||||||
|
resultFileName: fileName || undefined,
|
||||||
|
resultFileData: resultType === 'file' ? fileData : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = completePlan(plan.id, payload);
|
||||||
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
||||||
alert(response.message || '计划未满足完成条件');
|
alert(response.message || '计划未满足完成条件');
|
||||||
return;
|
return;
|
||||||
@@ -98,7 +168,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
|
||||||
|
{plan.type === 'product' && (
|
||||||
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
||||||
|
)}
|
||||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
|
||||||
|
{plan.type === 'product' && plan.reviewResult && (
|
||||||
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,6 +274,20 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
|
||||||
|
<div className={`rounded-lg border p-3 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
|
||||||
|
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
|
||||||
|
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||||
|
</div>
|
||||||
|
{plan.reviewResult === 'failed' && (
|
||||||
|
<div className="mt-2 space-y-1 text-[12px] text-red-700">
|
||||||
|
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
|
||||||
|
{plan.reviewFailureReason && <div>原因:{plan.reviewFailureReason}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{plan.remark && (
|
{plan.remark && (
|
||||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||||
<div className="text-[11px] text-[var(--ink-muted)] mb-1">备注</div>
|
<div className="text-[11px] text-[var(--ink-muted)] mb-1">备注</div>
|
||||||
@@ -223,22 +313,90 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
{/* Complete with result */}
|
{/* Complete with result */}
|
||||||
{showComplete && (
|
{showComplete && (
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
||||||
<div className="text-[11px] font-medium text-emerald-700">提交成果</div>
|
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
|
||||||
<input value={resultTitle} onChange={(e) => 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" />
|
{isProductReviewPlan ? (
|
||||||
<div className="flex gap-2">
|
<>
|
||||||
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" />链接</button>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" />文件</button>
|
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
|
||||||
</div>
|
<button
|
||||||
{resultType === 'link' ? (
|
key={result}
|
||||||
<input value={resultUrl} onChange={(e) => 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" />
|
onClick={() => setReviewResult(result)}
|
||||||
|
className={`h-8 rounded-lg border text-[11px] font-medium ${reviewResult === result ? 'border-[var(--accent)] bg-white text-[var(--accent)]' : 'border-[var(--line)] bg-white/70 text-[var(--ink-soft)]'}`}
|
||||||
|
>
|
||||||
|
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{reviewResult === 'failed' && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
|
||||||
|
<label key={option.value} className="flex min-h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--line)] bg-white/70 px-2 py-1 text-[11px] text-[var(--ink-soft)]">
|
||||||
|
<input type="checkbox" checked={reviewFailureTypes.has(option.value)} onChange={() => toggleFailureType(option.value)} className="h-3 w-3 shrink-0 rounded" />
|
||||||
|
<span className="leading-4">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={reviewFailureReason}
|
||||||
|
onChange={(e) => setReviewFailureReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="写清楚具体问题、影响范围和建议调整方向"
|
||||||
|
className="w-full rounded-lg border border-[var(--line)] bg-white px-2 py-2 text-[12px] focus:border-[var(--accent)] focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<>
|
||||||
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
|
{isProductDesignPlan && (
|
||||||
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
|
||||||
</div>
|
请确认产品方案评审已通过后再提交原型地址;这里不再上传 Axure 文件。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '成果标题(如 v1.0 原型地址)' : '成果标题(必填,如 v1.0 产品方案)'} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
{!isProductDesignPlan && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" />链接</button>
|
||||||
|
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" />文件</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultType === 'link' || isProductDesignPlan ? (
|
||||||
|
<input value={resultUrl} onChange={(e) => 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" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
|
||||||
|
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isProductDesignPlan && (
|
||||||
|
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-white/70 px-3 py-2 text-[11px] text-[var(--ink-soft)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prototypeReviewConfirmed}
|
||||||
|
onChange={(e) => setPrototypeReviewConfirmed(e.target.checked)}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 rounded"
|
||||||
|
/>
|
||||||
|
<span>已确认该产品设计方案评审通过,可以作为后续开发任务和测试用例拆解依据。</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleSubmitResult} disabled={!completionState.canSubmitResult || !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData)} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50">确认提交</button>
|
<button
|
||||||
|
onClick={handleSubmitResult}
|
||||||
|
disabled={
|
||||||
|
!completionState.canSubmitResult
|
||||||
|
|| (isProductReviewPlan
|
||||||
|
? reviewResult === 'failed' && (reviewFailureTypes.size === 0 || !reviewFailureReason.trim())
|
||||||
|
: isProductDesignPlan
|
||||||
|
? !resultTitle.trim() || !resultUrl.trim() || !prototypeReviewConfirmed
|
||||||
|
: !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData))
|
||||||
|
}
|
||||||
|
className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
确认提交
|
||||||
|
</button>
|
||||||
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +414,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
)}
|
)}
|
||||||
{plan.status === 'in_progress' && (
|
{plan.status === 'in_progress' && (
|
||||||
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
提交完成
|
{getSubmitActionLabel(plan)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, 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 { VersionPlan, PlanTask } from '@/lib/version-plan';
|
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||||
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
import {
|
||||||
|
calcPlanDuration,
|
||||||
|
formatDuration,
|
||||||
|
calcTotalDuration,
|
||||||
|
calcPlanProgress,
|
||||||
|
calcLinkedReqProgress,
|
||||||
|
PRODUCT_PLAN_KIND_LABEL,
|
||||||
|
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||||
|
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||||
|
} from '@/lib/version-plan';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { FieldError } from '@/components/FieldError';
|
import { FieldError } from '@/components/FieldError';
|
||||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||||
@@ -11,6 +20,7 @@ import type { VersionWithContext } from '@/lib/derive';
|
|||||||
import type { Requirement } from '@/lib/requirement';
|
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';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plans: VersionPlan[];
|
plans: VersionPlan[];
|
||||||
@@ -24,7 +34,7 @@ interface Props {
|
|||||||
allRequirements?: Requirement[];
|
allRequirements?: Requirement[];
|
||||||
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||||
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void;
|
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +46,24 @@ const STATUS_STYLE = {
|
|||||||
};
|
};
|
||||||
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
||||||
|
|
||||||
|
function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
|
||||||
|
return plan.productPlanKind ?? 'design';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmitActionLabel(plan: VersionPlan): string {
|
||||||
|
if (plan.type === 'product') {
|
||||||
|
return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
|
||||||
|
}
|
||||||
|
return '提交成果';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||||||
|
if (!types?.length) return [];
|
||||||
|
return types
|
||||||
|
.map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: 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);
|
||||||
@@ -96,6 +124,16 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
|
||||||
{STATUS_LABEL[effectiveStatus]}
|
{STATUS_LABEL[effectiveStatus]}
|
||||||
</span>
|
</span>
|
||||||
|
{plan.type === 'product' && (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
||||||
|
{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plan.type === 'product' && plan.reviewResult && (
|
||||||
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
||||||
<span>计划:{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</span>
|
<span>计划:{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</span>
|
||||||
@@ -120,6 +158,19 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
|
||||||
|
<div className={`mt-2 rounded-lg border px-3 py-2 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
|
||||||
|
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
|
||||||
|
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||||
|
</div>
|
||||||
|
{plan.reviewResult === 'failed' && (
|
||||||
|
<div className="mt-1 space-y-1 text-[11px] text-red-700">
|
||||||
|
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
|
||||||
|
{plan.reviewFailureReason && <div>原因:{plan.reviewFailureReason}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 子任务 */}
|
{/* 子任务 */}
|
||||||
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
|
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
@@ -226,8 +277,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
)}
|
)}
|
||||||
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
||||||
<div className="mt-3 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
<div className="mt-3 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
||||||
<span className="text-[12px] text-green-700">已满足提交成果条件</span>
|
<span className="text-[12px] text-green-700">已满足{getSubmitActionLabel(plan)}条件</span>
|
||||||
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果</button>
|
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">{getSubmitActionLabel(plan)}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,6 +308,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
|
|
||||||
{completingPlan && (
|
{completingPlan && (
|
||||||
<CompleteModal
|
<CompleteModal
|
||||||
|
plan={completingPlan}
|
||||||
onClose={() => setCompletingPlan(null)}
|
onClose={() => setCompletingPlan(null)}
|
||||||
onSubmit={(result) => {
|
onSubmit={(result) => {
|
||||||
const response = onComplete(completingPlan.id, result);
|
const response = onComplete(completingPlan.id, result);
|
||||||
@@ -289,6 +341,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
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 ?? '');
|
||||||
|
const [productPlanKind, setProductPlanKind] = useState<ProductPlanKind>(initial?.productPlanKind ?? 'design');
|
||||||
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
|
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||||||
@@ -318,6 +371,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
status: initial?.status ?? 'pending',
|
status: initial?.status ?? 'pending',
|
||||||
|
productPlanKind: planType === 'product' ? productPlanKind : undefined,
|
||||||
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
|
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
|
||||||
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
|
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
|
||||||
remark: remark.trim() || undefined,
|
remark: remark.trim() || undefined,
|
||||||
@@ -342,6 +396,34 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
<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={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" />
|
||||||
</div>
|
</div>
|
||||||
|
{planType === 'product' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">方案类型</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(['design', 'review'] as ProductPlanKind[]).map((kind) => (
|
||||||
|
<label
|
||||||
|
key={kind}
|
||||||
|
className={`flex min-h-16 cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors ${productPlanKind === kind ? 'border-[var(--accent)] bg-[var(--accent-soft)]' : 'border-[var(--line)] bg-[var(--bg-card)] hover:bg-[var(--bg-subtle)]'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="productPlanKind"
|
||||||
|
value={kind}
|
||||||
|
checked={productPlanKind === kind}
|
||||||
|
onChange={() => setProductPlanKind(kind)}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-[12px] font-medium text-[var(--ink)]">{PRODUCT_PLAN_KIND_LABEL[kind]}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||||
|
{kind === 'design' ? '提交最终原型链接,用于后续 AI 拆解。' : '记录方案是否通过评审,不通过时沉淀原因类型。'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<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>
|
||||||
@@ -461,15 +543,24 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompleteModal({ onClose, onSubmit }: {
|
function CompleteModal({ plan, onClose, onSubmit }: {
|
||||||
|
plan: VersionPlan;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
onSubmit: (result: PlanResultPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||||
const [resultTitle, setResultTitle] = useState('');
|
const [resultTitle, setResultTitle] = useState('');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [fileData, setFileData] = useState('');
|
const [fileData, setFileData] = useState('');
|
||||||
|
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
|
||||||
|
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
|
||||||
|
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
|
||||||
|
const [reviewFailureReason, setReviewFailureReason] = useState('');
|
||||||
|
|
||||||
|
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||||
|
const isProductDesignPlan = productPlanKind === 'design';
|
||||||
|
const isProductReviewPlan = productPlanKind === 'review';
|
||||||
|
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -480,35 +571,143 @@ function CompleteModal({ onClose, onSubmit }: {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
|
const canSubmit = isProductReviewPlan
|
||||||
|
? reviewResult === 'passed' || (reviewFailureTypes.size > 0 && reviewFailureReason.trim().length > 0)
|
||||||
|
: isProductDesignPlan
|
||||||
|
? resultTitle.trim().length > 0 && url.trim().length > 0 && prototypeReviewConfirmed
|
||||||
|
: resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
|
||||||
|
|
||||||
|
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
||||||
|
const next = new Set(reviewFailureTypes);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
setReviewFailureTypes(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isProductReviewPlan) {
|
||||||
|
onSubmit({
|
||||||
|
productPlanKind: 'review',
|
||||||
|
reviewResult,
|
||||||
|
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
|
||||||
|
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
|
||||||
|
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductDesignPlan) {
|
||||||
|
onSubmit({
|
||||||
|
productPlanKind: 'design',
|
||||||
|
resultType: 'link',
|
||||||
|
resultTitle: resultTitle.trim(),
|
||||||
|
resultUrl: url.trim(),
|
||||||
|
prototypeReviewConfirmed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
resultType,
|
||||||
|
resultTitle: resultTitle.trim(),
|
||||||
|
resultUrl: resultType === 'link' ? url.trim() : fileData,
|
||||||
|
resultFileName: fileName || undefined,
|
||||||
|
resultFileData: resultType === 'file' ? fileData : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">提交成果</h3>
|
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{getSubmitActionLabel(plan)}</h3>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
{isProductReviewPlan ? (
|
||||||
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">成果标题<span className="text-red-500 ml-0.5">*</span></label>
|
<>
|
||||||
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" 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" />
|
<div>
|
||||||
</div>
|
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">评审结论<span className="text-red-500 ml-0.5">*</span></label>
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接</button>
|
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
|
||||||
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件</button>
|
<button
|
||||||
</div>
|
key={result}
|
||||||
{resultType === 'link' ? (
|
type="button"
|
||||||
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." 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" />
|
onClick={() => setReviewResult(result)}
|
||||||
|
className={`h-9 rounded-lg border text-[12px] font-medium transition-colors ${reviewResult === result ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||||
|
>
|
||||||
|
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{reviewResult === 'failed' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">不通过类型<span className="text-red-500 ml-0.5">*</span></label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
|
||||||
|
<label key={option.value} className="flex min-h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--line)] px-2 py-1 text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||||
|
<input type="checkbox" checked={reviewFailureTypes.has(option.value)} onChange={() => toggleFailureType(option.value)} className="h-3 w-3 shrink-0 rounded" />
|
||||||
|
<span className="leading-4">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">原因说明<span className="text-red-500 ml-0.5">*</span></label>
|
||||||
|
<textarea
|
||||||
|
value={reviewFailureReason}
|
||||||
|
onChange={(e) => setReviewFailureReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="写清楚具体问题、影响范围和建议调整方向"
|
||||||
|
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<>
|
||||||
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
|
{isProductDesignPlan && (
|
||||||
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">已选:{fileName}</p>}
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
|
||||||
</div>
|
请确认产品方案评审已通过后再提交原型地址;这里不再上传 Axure 文件,AI 后续会优先读取可访问的原型链接。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">成果标题<span className="text-red-500 ml-0.5">*</span></label>
|
||||||
|
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '如 v1.0 原型地址' : '如 v1.0 产品方案'} 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" />
|
||||||
|
</div>
|
||||||
|
{!isProductDesignPlan && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接</button>
|
||||||
|
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultType === 'link' || isProductDesignPlan ? (
|
||||||
|
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." 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" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
|
||||||
|
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">已选:{fileName}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isProductDesignPlan && (
|
||||||
|
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] text-[var(--ink-soft)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prototypeReviewConfirmed}
|
||||||
|
onChange={(e) => setPrototypeReviewConfirmed(e.target.checked)}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 rounded"
|
||||||
|
/>
|
||||||
|
<span>已确认该产品设计方案评审通过,可以作为后续开发任务和测试用例拆解依据。</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||||
<button onClick={() => onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认</button>
|
<button onClick={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
138
apps/web/lib/ai-decompose-dedupe.test.ts
Normal file
138
apps/web/lib/ai-decompose-dedupe.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { filterDuplicateDecomposeDrafts } from './ai-decompose-dedupe';
|
||||||
|
import type { AgentDecomposeResult } from '@ftb/shared';
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
import type { TestCase } from './test-case';
|
||||||
|
import type { TaskCategory } from './task-category';
|
||||||
|
|
||||||
|
const categories: TaskCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'cat-frontend',
|
||||||
|
code: 'frontend_interaction',
|
||||||
|
name: '前端交互',
|
||||||
|
group: 'development',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-test-functional',
|
||||||
|
code: 'test_functional',
|
||||||
|
name: '功能测试',
|
||||||
|
group: 'testing',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const requirements = [{ id: 'req-1', code: 'REQ001' }];
|
||||||
|
|
||||||
|
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
|
||||||
|
return {
|
||||||
|
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||||
|
devTaskDrafts: [],
|
||||||
|
testCaseDrafts: [],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('filters dev task drafts already adopted into existing tasks', () => {
|
||||||
|
const existingDevTasks = [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
title: '实现拖拽排序',
|
||||||
|
categoryId: 'cat-frontend',
|
||||||
|
references: [
|
||||||
|
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
|
||||||
|
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as DevTask[];
|
||||||
|
|
||||||
|
const result = filterDuplicateDecomposeDrafts(
|
||||||
|
makeResult({
|
||||||
|
devTaskDrafts: [
|
||||||
|
{
|
||||||
|
title: '实现拖拽排序',
|
||||||
|
categoryCode: 'frontend_interaction',
|
||||||
|
priority: 'P2',
|
||||||
|
aiEstimateHours: 0.25,
|
||||||
|
references: [
|
||||||
|
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
|
||||||
|
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ existingDevTasks, existingTestCases: [], categories, requirements },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.removedDevTaskCount, 1);
|
||||||
|
assert.equal(result.result.devTaskDrafts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters test case drafts already adopted into existing test cases', () => {
|
||||||
|
const existingTestCases = [
|
||||||
|
{
|
||||||
|
id: 'tc-1',
|
||||||
|
title: '验证拖拽排序成功',
|
||||||
|
categoryId: 'cat-test-functional',
|
||||||
|
references: [
|
||||||
|
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
|
||||||
|
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as TestCase[];
|
||||||
|
|
||||||
|
const result = filterDuplicateDecomposeDrafts(
|
||||||
|
makeResult({
|
||||||
|
testCaseDrafts: [
|
||||||
|
{
|
||||||
|
title: '验证拖拽排序成功',
|
||||||
|
description: '拖拽后顺序保存',
|
||||||
|
categoryCode: 'test_functional',
|
||||||
|
priority: 'P2',
|
||||||
|
aiEstimateHours: 0.2,
|
||||||
|
references: [
|
||||||
|
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
|
||||||
|
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ existingDevTasks: [], existingTestCases, categories, requirements },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.removedTestCaseCount, 1);
|
||||||
|
assert.equal(result.result.testCaseDrafts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps drafts with same references but different normalized title', () => {
|
||||||
|
const existingDevTasks = [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
title: '实现拖拽排序',
|
||||||
|
categoryId: 'cat-frontend',
|
||||||
|
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
|
||||||
|
},
|
||||||
|
] as DevTask[];
|
||||||
|
|
||||||
|
const result = filterDuplicateDecomposeDrafts(
|
||||||
|
makeResult({
|
||||||
|
devTaskDrafts: [
|
||||||
|
{
|
||||||
|
title: '保存拖拽排序结果',
|
||||||
|
categoryCode: 'frontend_interaction',
|
||||||
|
priority: 'P2',
|
||||||
|
aiEstimateHours: 0.5,
|
||||||
|
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ existingDevTasks, existingTestCases: [], categories, requirements },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.removedDevTaskCount, 0);
|
||||||
|
assert.equal(result.result.devTaskDrafts.length, 1);
|
||||||
|
});
|
||||||
125
apps/web/lib/ai-decompose-dedupe.ts
Normal file
125
apps/web/lib/ai-decompose-dedupe.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type {
|
||||||
|
AgentDecomposeResult,
|
||||||
|
AgentDevTaskDraft,
|
||||||
|
AgentReference,
|
||||||
|
AgentTestCaseDraft,
|
||||||
|
} from '@ftb/shared';
|
||||||
|
import type { DevTask, Reference } from './dev-task';
|
||||||
|
import type { TestCase } from './test-case';
|
||||||
|
import type { TaskCategory } from './task-category';
|
||||||
|
|
||||||
|
interface RequirementIdentity {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecomposeDedupeInput {
|
||||||
|
existingDevTasks: DevTask[];
|
||||||
|
existingTestCases: TestCase[];
|
||||||
|
categories: TaskCategory[];
|
||||||
|
requirements: RequirementIdentity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecomposeDedupeResult {
|
||||||
|
result: AgentDecomposeResult;
|
||||||
|
removedDevTaskCount: number;
|
||||||
|
removedTestCaseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDuplicateDecomposeDrafts(
|
||||||
|
result: AgentDecomposeResult,
|
||||||
|
input: DecomposeDedupeInput,
|
||||||
|
): DecomposeDedupeResult {
|
||||||
|
const devFingerprints = new Set(
|
||||||
|
input.existingDevTasks
|
||||||
|
.map((task) => existingItemFingerprint(task.title, task.categoryId, task.references ?? [], input))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const testCaseFingerprints = new Set(
|
||||||
|
input.existingTestCases
|
||||||
|
.map((testCase) => existingItemFingerprint(testCase.title, testCase.categoryId, testCase.references ?? [], input))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredDevTaskDrafts = filterDrafts(result.devTaskDrafts, devFingerprints, input, draftFingerprint);
|
||||||
|
const filteredTestCaseDrafts = filterDrafts(result.testCaseDrafts, testCaseFingerprints, input, draftFingerprint);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
...result,
|
||||||
|
devTaskDrafts: filteredDevTaskDrafts.items,
|
||||||
|
testCaseDrafts: filteredTestCaseDrafts.items,
|
||||||
|
},
|
||||||
|
removedDevTaskCount: result.devTaskDrafts.length - filteredDevTaskDrafts.items.length,
|
||||||
|
removedTestCaseCount: result.testCaseDrafts.length - filteredTestCaseDrafts.items.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDrafts<T extends AgentDevTaskDraft | AgentTestCaseDraft>(
|
||||||
|
drafts: T[],
|
||||||
|
existingFingerprints: Set<string>,
|
||||||
|
input: DecomposeDedupeInput,
|
||||||
|
getFingerprint: (draft: T, input: DecomposeDedupeInput) => string,
|
||||||
|
): { items: T[] } {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: T[] = [];
|
||||||
|
for (const draft of drafts) {
|
||||||
|
const fingerprint = getFingerprint(draft, input);
|
||||||
|
if (existingFingerprints.has(fingerprint) || seen.has(fingerprint)) continue;
|
||||||
|
seen.add(fingerprint);
|
||||||
|
items.push(draft);
|
||||||
|
}
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFingerprint(
|
||||||
|
draft: AgentDevTaskDraft | AgentTestCaseDraft,
|
||||||
|
input: DecomposeDedupeInput,
|
||||||
|
): string {
|
||||||
|
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingItemFingerprint(
|
||||||
|
title: string,
|
||||||
|
categoryId: string,
|
||||||
|
references: Reference[],
|
||||||
|
input: DecomposeDedupeInput,
|
||||||
|
): string {
|
||||||
|
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
|
||||||
|
return buildFingerprint(title, categoryCode, references, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFingerprint(
|
||||||
|
title: string,
|
||||||
|
categoryCode: string,
|
||||||
|
references: Array<AgentReference | Reference>,
|
||||||
|
input: DecomposeDedupeInput,
|
||||||
|
): string {
|
||||||
|
const refKey = references
|
||||||
|
.map((ref) => normalizeReferenceKey(ref, input.requirements))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(title: string): string {
|
||||||
|
return title
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s::,,.。;;、\-—_]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReferenceKey(
|
||||||
|
ref: AgentReference | Reference,
|
||||||
|
requirements: RequirementIdentity[],
|
||||||
|
): string {
|
||||||
|
const rawId = String(ref.id || '').trim();
|
||||||
|
if (!rawId) return '';
|
||||||
|
if (ref.type === 'requirement') {
|
||||||
|
const matched = requirements.find((requirement) => requirement.id === rawId || requirement.code === rawId);
|
||||||
|
return `requirement:${matched?.id ?? rawId}`;
|
||||||
|
}
|
||||||
|
return `${ref.type}:${rawId.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
32
apps/web/lib/ai-decompose-report.test.ts
Normal file
32
apps/web/lib/ai-decompose-report.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { formatReportRequirementLabel } from './ai-decompose-report';
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{
|
||||||
|
id: 'rea-17823017283970',
|
||||||
|
code: 'REQ0010',
|
||||||
|
title: '支持主题拖拽排序',
|
||||||
|
description: '用户可以调整主题顺序',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('formatReportRequirementLabel resolves internal requirement id to code and title', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatReportRequirementLabel('rea-17823017283970', requirements),
|
||||||
|
'REQ0010 支持主题拖拽排序',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatReportRequirementLabel resolves requirement code to code and title', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatReportRequirementLabel('REQ0010', requirements),
|
||||||
|
'REQ0010 支持主题拖拽排序',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatReportRequirementLabel falls back to raw id when requirement is unknown', () => {
|
||||||
|
assert.equal(formatReportRequirementLabel('rea-missing', requirements), 'rea-missing');
|
||||||
|
});
|
||||||
|
|
||||||
13
apps/web/lib/ai-decompose-report.ts
Normal file
13
apps/web/lib/ai-decompose-report.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface ReportRequirement {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReportRequirementLabel(reqId: string, requirements: ReportRequirement[]): string {
|
||||||
|
const requirement = requirements.find((item) => item.id === reqId || item.code === reqId);
|
||||||
|
if (!requirement) return reqId;
|
||||||
|
return `${requirement.code} ${requirement.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,24 +2,25 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clampDevEstimateHours,
|
clampDevAiEstimateHours,
|
||||||
clampTestCaseEstimateHours,
|
clampTestCaseAiEstimateHours,
|
||||||
getDefaultTestCaseEstimateHours,
|
getDefaultTestCaseAiEstimateHours,
|
||||||
} from './ai-estimation-policy';
|
} from './ai-estimation-policy';
|
||||||
|
|
||||||
test('clamps simple frontend interaction to AI-assisted range', () => {
|
test('clamps simple frontend interaction to AI-assisted range', () => {
|
||||||
assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
|
assert.equal(clampDevAiEstimateHours('frontend_interaction', 5), 0.5);
|
||||||
assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
|
assert.equal(clampDevAiEstimateHours('frontend_interaction', 0.1), 0.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps backend API within strict range', () => {
|
test('keeps backend API within strict range', () => {
|
||||||
assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
|
assert.equal(clampDevAiEstimateHours('backend_api', 0.25), 0.5);
|
||||||
assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
|
assert.equal(clampDevAiEstimateHours('backend_api', 2), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('defaults and clamps test case estimates', () => {
|
test('defaults and clamps test case AI estimates below half an hour when appropriate', () => {
|
||||||
assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
|
assert.equal(getDefaultTestCaseAiEstimateHours('test_functional'), 0.2);
|
||||||
assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
|
assert.equal(clampTestCaseAiEstimateHours('test_functional', 2), 0.3);
|
||||||
assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
|
assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
|
||||||
assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
|
assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
|
||||||
|
assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,41 +3,58 @@ import type { AgentTaskCategoryCode } from '@ftb/shared';
|
|||||||
type EstimateRange = { min: number; max: number; fallback: number };
|
type EstimateRange = { min: number; max: number; fallback: number };
|
||||||
|
|
||||||
const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||||
frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
|
frontend_development: { min: 0.25, max: 1, fallback: 0.5 },
|
||||||
frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
|
frontend_interaction: { min: 0.25, max: 0.5, fallback: 0.25 },
|
||||||
backend_development: { min: 1, max: 3, fallback: 2 },
|
backend_development: { min: 0.75, max: 2, fallback: 1.25 },
|
||||||
backend_api: { min: 0.75, max: 1.5, fallback: 1 },
|
backend_api: { min: 0.5, max: 1, fallback: 0.75 },
|
||||||
database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
|
database_schema: { min: 0.25, max: 0.5, fallback: 0.25 },
|
||||||
api_integration: { min: 0.75, max: 1.5, fallback: 1 },
|
api_integration: { min: 0.5, max: 1.25, fallback: 0.75 },
|
||||||
data_processing: { min: 1, max: 2, fallback: 1.5 },
|
data_processing: { min: 0.75, max: 1.5, fallback: 1 },
|
||||||
implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
|
implementation_support: { min: 0.25, max: 1, fallback: 0.5 },
|
||||||
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
|
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||||
test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
|
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
|
||||||
test_api: { min: 0.5, max: 1, fallback: 0.5 },
|
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||||
test_exception: { min: 0.5, max: 1, fallback: 0.5 },
|
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||||
test_compatibility: { min: 0.5, max: 1, fallback: 1 },
|
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function roundQuarterHour(hours: number): number {
|
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||||
return Math.round(hours * 4) / 4;
|
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
|
||||||
|
test_api: { min: 0.25, max: 2, fallback: 0.5 },
|
||||||
|
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
|
||||||
|
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function roundToStep(hours: number, step: number): number {
|
||||||
|
return Number((Math.round(hours / step) * step).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampToRange(raw: number | undefined, range: EstimateRange): number {
|
function clampToRange(raw: number | undefined, range: EstimateRange, step: number): number {
|
||||||
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
|
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
|
||||||
return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
|
return roundToStep(Math.min(range.max, Math.max(range.min, base)), step);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
export function clampDevAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||||
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
|
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.25, max: 1.5, fallback: 0.75 }, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
export function getDefaultTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
||||||
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
|
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
export function clampTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||||
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
|
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clampDevEstimateHours = clampDevAiEstimateHours;
|
||||||
|
|
||||||
|
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
||||||
|
return (EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional).fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||||
|
return clampToRange(raw, EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional, 0.25);
|
||||||
}
|
}
|
||||||
|
|||||||
52
apps/web/lib/dev-task.test.ts
Normal file
52
apps/web/lib/dev-task.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getEstimateHours, type DevTask } from './dev-task';
|
||||||
|
|
||||||
|
function makeTask(patch: Partial<DevTask>): DevTask {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
taskNo: 'DEV-001',
|
||||||
|
requirementId: 'req-1',
|
||||||
|
title: '实现筛选',
|
||||||
|
categoryId: 'cat-frontend',
|
||||||
|
assigneeId: '张三',
|
||||||
|
priority: 'P2',
|
||||||
|
expectedStartAt: '',
|
||||||
|
expectedEndAt: '',
|
||||||
|
status: 'todo',
|
||||||
|
isBlocked: false,
|
||||||
|
createdBy: 'tester',
|
||||||
|
createdAt: '2026-06-26T01:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getEstimateHours prefers executor estimate over AI estimate', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
estimateHours: 1.5,
|
||||||
|
aiEstimateHours: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getEstimateHours(task), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getEstimateHours falls back to AI estimate before schedule-derived estimate', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
aiEstimateHours: 0.25,
|
||||||
|
expectedStartAt: '2026-06-26T01:00:00.000Z',
|
||||||
|
expectedEndAt: '2026-06-26T03:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getEstimateHours(task), 0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getEstimateHours uses schedule when no executor or AI estimate exists', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
expectedStartAt: '2026-06-26T01:00:00.000Z',
|
||||||
|
expectedEndAt: '2026-06-26T03:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getEstimateHours(task), 2);
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ export interface DevTask {
|
|||||||
expectedStartAt: string;
|
expectedStartAt: string;
|
||||||
expectedEndAt: string;
|
expectedEndAt: string;
|
||||||
estimateHours?: number;
|
estimateHours?: number;
|
||||||
|
aiEstimateHours?: number;
|
||||||
actualStartAt?: string;
|
actualStartAt?: string;
|
||||||
actualEndAt?: string;
|
actualEndAt?: string;
|
||||||
|
|
||||||
@@ -98,12 +99,19 @@ export function formatHours(hours: number): string {
|
|||||||
|
|
||||||
export function getEstimateHours(task: DevTask): number {
|
export function getEstimateHours(task: DevTask): number {
|
||||||
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
|
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
|
||||||
return Math.round(task.estimateHours * 2) / 2;
|
return roundEffortHours(task.estimateHours);
|
||||||
|
}
|
||||||
|
if (typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0) {
|
||||||
|
return roundEffortHours(task.aiEstimateHours);
|
||||||
}
|
}
|
||||||
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
|
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
|
||||||
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
|
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundEffortHours(hours: number): number {
|
||||||
|
return Number(hours.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
export function getActualHours(task: DevTask, now: Date = new Date()): number {
|
export function getActualHours(task: DevTask, now: Date = new Date()): number {
|
||||||
if (!task.actualStartAt) return 0;
|
if (!task.actualStartAt) return 0;
|
||||||
const end = task.actualEndAt ?? now.toISOString();
|
const end = task.actualEndAt ?? now.toISOString();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ test('normalizeTestCase keeps existing categoryId', () => {
|
|||||||
assert.equal(tc.categoryId, 'cat-test-api');
|
assert.equal(tc.categoryId, 'cat-test-api');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeTestCase backfills missing estimateHours', () => {
|
test('normalizeTestCase keeps missing executor estimate empty', () => {
|
||||||
const tc = normalizeTestCase({
|
const tc = normalizeTestCase({
|
||||||
id: 'tc-1',
|
id: 'tc-1',
|
||||||
caseNo: 'TC-001',
|
caseNo: 'TC-001',
|
||||||
@@ -57,8 +57,45 @@ test('normalizeTestCase backfills missing estimateHours', () => {
|
|||||||
updatedAt: '2026-06-25',
|
updatedAt: '2026-06-25',
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
assert.equal(tc.estimateHours, 0.5);
|
assert.equal(tc.estimateHours, undefined);
|
||||||
assert.equal(getTestCaseEstimateHours(tc), 0.5);
|
assert.equal(getTestCaseEstimateHours(tc), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTestCaseEstimateHours prefers executor estimate over AI estimate', () => {
|
||||||
|
const tc = normalizeTestCase({
|
||||||
|
id: 'tc-1',
|
||||||
|
caseNo: 'TC-001',
|
||||||
|
versionId: 'v1',
|
||||||
|
title: '登录正常',
|
||||||
|
priority: 'P2',
|
||||||
|
status: 'pending',
|
||||||
|
categoryId: 'cat-test-api',
|
||||||
|
estimateHours: 0.7,
|
||||||
|
aiEstimateHours: 0.2,
|
||||||
|
createdBy: 'tester',
|
||||||
|
createdAt: '2026-06-25',
|
||||||
|
updatedAt: '2026-06-25',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
assert.equal(getTestCaseEstimateHours(tc), 0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTestCaseEstimateHours falls back to AI estimate', () => {
|
||||||
|
const tc = normalizeTestCase({
|
||||||
|
id: 'tc-1',
|
||||||
|
caseNo: 'TC-001',
|
||||||
|
versionId: 'v1',
|
||||||
|
title: '登录正常',
|
||||||
|
priority: 'P2',
|
||||||
|
status: 'pending',
|
||||||
|
categoryId: 'cat-test-api',
|
||||||
|
aiEstimateHours: 0.2,
|
||||||
|
createdBy: 'tester',
|
||||||
|
createdAt: '2026-06-25',
|
||||||
|
updatedAt: '2026-06-25',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
assert.equal(getTestCaseEstimateHours(tc), 0.2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test case status labels use waiting and testing wording', () => {
|
test('test case status labels use waiting and testing wording', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface TestCase {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
status: TestCaseStatus;
|
status: TestCaseStatus;
|
||||||
estimateHours?: number;
|
estimateHours?: number;
|
||||||
|
aiEstimateHours?: number;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
executedAt?: string;
|
executedAt?: string;
|
||||||
@@ -88,7 +89,8 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
|
|||||||
priority: testCase.priority || 'P2',
|
priority: testCase.priority || 'P2',
|
||||||
assigneeId: testCase.assigneeId,
|
assigneeId: testCase.assigneeId,
|
||||||
status: testCase.status || 'pending',
|
status: testCase.status || 'pending',
|
||||||
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
|
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
|
||||||
|
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
|
||||||
startedAt: testCase.startedAt,
|
startedAt: testCase.startedAt,
|
||||||
completedAt: testCase.completedAt,
|
completedAt: testCase.completedAt,
|
||||||
executedAt: testCase.executedAt,
|
executedAt: testCase.executedAt,
|
||||||
@@ -125,9 +127,13 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTestCaseEstimateHours(tc: TestCase): number {
|
export function getTestCaseEstimateHours(tc: TestCase): number {
|
||||||
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
|
if (typeof tc.estimateHours === 'number' && tc.estimateHours > 0) {
|
||||||
? Math.round(tc.estimateHours * 2) / 2
|
return Number(tc.estimateHours.toFixed(2));
|
||||||
: 0.5;
|
}
|
||||||
|
if (typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0) {
|
||||||
|
return Number(tc.aiEstimateHours.toFixed(2));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ test('allows completion only after result exists', () => {
|
|||||||
resultType: 'link',
|
resultType: 'link',
|
||||||
resultTitle: '原型',
|
resultTitle: '原型',
|
||||||
resultUrl: 'https://example.com/prototype',
|
resultUrl: 'https://example.com/prototype',
|
||||||
|
prototypeReviewConfirmed: true,
|
||||||
}));
|
}));
|
||||||
assert.equal(state.canComplete, true);
|
assert.equal(state.canComplete, true);
|
||||||
});
|
});
|
||||||
@@ -83,3 +84,61 @@ test('detects link and file result payloads', () => {
|
|||||||
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
|
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
|
||||||
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
|
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('product design plans require a prototype link and review confirmation', () => {
|
||||||
|
const withoutReviewConfirmation = getPlanCompletionState(plan({
|
||||||
|
productPlanKind: 'design',
|
||||||
|
completedRequirementIds: ['r1'],
|
||||||
|
resultType: 'link',
|
||||||
|
resultTitle: 'Prototype',
|
||||||
|
resultUrl: 'https://example.com/prototype',
|
||||||
|
} as Partial<VersionPlan>));
|
||||||
|
assert.equal(withoutReviewConfirmation.canComplete, false);
|
||||||
|
|
||||||
|
const withReviewConfirmation = getPlanCompletionState(plan({
|
||||||
|
productPlanKind: 'design',
|
||||||
|
completedRequirementIds: ['r1'],
|
||||||
|
resultType: 'link',
|
||||||
|
resultTitle: 'Prototype',
|
||||||
|
resultUrl: 'https://example.com/prototype',
|
||||||
|
prototypeReviewConfirmed: true,
|
||||||
|
} as Partial<VersionPlan>));
|
||||||
|
assert.equal(withReviewConfirmation.canComplete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product design plans do not accept file results', () => {
|
||||||
|
assert.equal(hasPlanResult({
|
||||||
|
productPlanKind: 'design',
|
||||||
|
resultType: 'file',
|
||||||
|
resultTitle: 'Axure file',
|
||||||
|
resultFileName: 'prototype.rp',
|
||||||
|
resultFileData: 'data:application/octet-stream;base64,abc',
|
||||||
|
} as any), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product review plans can complete with a pass result without prototype link', () => {
|
||||||
|
const state = getPlanCompletionState(plan({
|
||||||
|
productPlanKind: 'review',
|
||||||
|
completedRequirementIds: ['r1'],
|
||||||
|
reviewResult: 'passed',
|
||||||
|
} as Partial<VersionPlan>));
|
||||||
|
assert.equal(state.canComplete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product review plans require failure type and detail when review fails', () => {
|
||||||
|
const missingFailureDetail = getPlanCompletionState(plan({
|
||||||
|
productPlanKind: 'review',
|
||||||
|
completedRequirementIds: ['r1'],
|
||||||
|
reviewResult: 'failed',
|
||||||
|
} as Partial<VersionPlan>));
|
||||||
|
assert.equal(missingFailureDetail.canComplete, false);
|
||||||
|
|
||||||
|
const completeFailureDetail = getPlanCompletionState(plan({
|
||||||
|
productPlanKind: 'review',
|
||||||
|
completedRequirementIds: ['r1'],
|
||||||
|
reviewResult: 'failed',
|
||||||
|
reviewFailureTypes: ['interaction_flow'],
|
||||||
|
reviewFailureReason: 'Critical path is missing empty and rollback states.',
|
||||||
|
} as Partial<VersionPlan>));
|
||||||
|
assert.equal(completeFailureDetail.canComplete, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { VersionPlan } from './version-plan';
|
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from './version-plan';
|
||||||
|
|
||||||
export interface PlanResultPayload {
|
export interface PlanResultPayload {
|
||||||
|
type?: VersionPlan['type'];
|
||||||
|
productPlanKind?: ProductPlanKind;
|
||||||
resultType?: 'link' | 'file';
|
resultType?: 'link' | 'file';
|
||||||
resultTitle?: string;
|
resultTitle?: string;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
resultFileName?: string;
|
resultFileName?: string;
|
||||||
resultFileData?: string;
|
resultFileData?: string;
|
||||||
|
prototypeReviewConfirmed?: boolean;
|
||||||
|
reviewResult?: ProductPlanReviewResult;
|
||||||
|
reviewFailureTypes?: ProductPlanReviewFailureType[];
|
||||||
|
reviewFailureReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanCompletionState {
|
export interface PlanCompletionState {
|
||||||
@@ -20,6 +26,23 @@ export interface PlanCompletionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasPlanResult(plan: PlanResultPayload): boolean {
|
export function hasPlanResult(plan: PlanResultPayload): boolean {
|
||||||
|
if (plan.productPlanKind === 'review') {
|
||||||
|
if (plan.reviewResult === 'passed') return true;
|
||||||
|
if (plan.reviewResult === 'failed') {
|
||||||
|
return Boolean(plan.reviewFailureTypes?.length && plan.reviewFailureReason?.trim());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.productPlanKind === 'design') {
|
||||||
|
return Boolean(
|
||||||
|
plan.resultType === 'link'
|
||||||
|
&& plan.resultTitle?.trim()
|
||||||
|
&& plan.resultUrl?.trim()
|
||||||
|
&& plan.prototypeReviewConfirmed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasTitle = Boolean(plan.resultTitle?.trim());
|
const hasTitle = Boolean(plan.resultTitle?.trim());
|
||||||
if (!hasTitle || !plan.resultType) return false;
|
if (!hasTitle || !plan.resultType) return false;
|
||||||
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
|
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
|
||||||
@@ -34,6 +57,11 @@ function requiresChecklist(plan: VersionPlan): boolean {
|
|||||||
return plan.type === 'research';
|
return plan.type === 'research';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProductPlanKind(plan: VersionPlan): ProductPlanKind | undefined {
|
||||||
|
if (plan.type !== 'product') return undefined;
|
||||||
|
return plan.productPlanKind ?? 'design';
|
||||||
|
}
|
||||||
|
|
||||||
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
||||||
const tasks = plan.tasks ?? [];
|
const tasks = plan.tasks ?? [];
|
||||||
const checklistTotal = tasks.length;
|
const checklistTotal = tasks.length;
|
||||||
@@ -52,8 +80,25 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canSubmitResult = missingReasons.length === 0;
|
const canSubmitResult = missingReasons.length === 0;
|
||||||
const hasResult = hasPlanResult(plan);
|
const productPlanKind = getProductPlanKind(plan);
|
||||||
if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
|
const resultPlan = productPlanKind ? { ...plan, productPlanKind } : plan;
|
||||||
|
const hasResult = hasPlanResult(resultPlan);
|
||||||
|
if (canSubmitResult && !hasResult) {
|
||||||
|
if (productPlanKind === 'design') {
|
||||||
|
if (plan.resultType === 'file') missingReasons.push('产品设计方案只支持原型链接');
|
||||||
|
if (plan.resultType === 'link' && plan.resultUrl?.trim() && !plan.prototypeReviewConfirmed) {
|
||||||
|
missingReasons.push('请先确认方案评审已通过');
|
||||||
|
} else {
|
||||||
|
missingReasons.push('尚未提交原型链接');
|
||||||
|
}
|
||||||
|
} else if (productPlanKind === 'review') {
|
||||||
|
if (!plan.reviewResult) missingReasons.push('请选择评审结论');
|
||||||
|
if (plan.reviewResult === 'failed' && !plan.reviewFailureTypes?.length) missingReasons.push('请选择评审不通过类型');
|
||||||
|
if (plan.reviewResult === 'failed' && !plan.reviewFailureReason?.trim()) missingReasons.push('请填写评审不通过原因');
|
||||||
|
} else {
|
||||||
|
missingReasons.push('尚未提交成果');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checklistTotal,
|
checklistTotal,
|
||||||
|
|||||||
@@ -1,4 +1,40 @@
|
|||||||
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
||||||
|
export type ProductPlanKind = 'design' | 'review';
|
||||||
|
export type ProductPlanReviewResult = 'passed' | 'failed';
|
||||||
|
export type ProductPlanReviewFailureType =
|
||||||
|
| 'requirement_mismatch'
|
||||||
|
| 'information_architecture'
|
||||||
|
| 'interaction_flow'
|
||||||
|
| 'state_coverage'
|
||||||
|
| 'edge_case_missing'
|
||||||
|
| 'business_rule_gap'
|
||||||
|
| 'role_permission_gap'
|
||||||
|
| 'data_rule_gap'
|
||||||
|
| 'copywriting_ambiguity'
|
||||||
|
| 'risk_dependency';
|
||||||
|
|
||||||
|
export const PRODUCT_PLAN_KIND_LABEL: Record<ProductPlanKind, string> = {
|
||||||
|
design: '设计方案',
|
||||||
|
review: '方案评审',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record<ProductPlanReviewResult, string> = {
|
||||||
|
passed: '评审通过',
|
||||||
|
failed: '评审不通过',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [
|
||||||
|
{ value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' },
|
||||||
|
{ value: 'information_architecture', label: '信息架构或页面层级不清晰' },
|
||||||
|
{ value: 'interaction_flow', label: '关键交互流程不闭环' },
|
||||||
|
{ value: 'state_coverage', label: '缺少状态、异常或空数据场景' },
|
||||||
|
{ value: 'edge_case_missing', label: '边界场景考虑不足' },
|
||||||
|
{ value: 'business_rule_gap', label: '业务规则、审批或口径缺失' },
|
||||||
|
{ value: 'role_permission_gap', label: '角色权限与可见范围不明确' },
|
||||||
|
{ value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' },
|
||||||
|
{ value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' },
|
||||||
|
{ value: 'risk_dependency', label: '依赖、风险或上线影响未说明' },
|
||||||
|
];
|
||||||
|
|
||||||
export interface PlanTask {
|
export interface PlanTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,11 +54,16 @@ export interface VersionPlan {
|
|||||||
tasks?: PlanTask[];
|
tasks?: PlanTask[];
|
||||||
completedRequirementIds?: string[];
|
completedRequirementIds?: string[];
|
||||||
linkedRequirementIds?: string[];
|
linkedRequirementIds?: string[];
|
||||||
|
productPlanKind?: ProductPlanKind;
|
||||||
resultType?: 'link' | 'file';
|
resultType?: 'link' | 'file';
|
||||||
resultTitle?: string;
|
resultTitle?: string;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
resultFileName?: string;
|
resultFileName?: string;
|
||||||
resultFileData?: string;
|
resultFileData?: string;
|
||||||
|
prototypeReviewConfirmed?: boolean;
|
||||||
|
reviewResult?: ProductPlanReviewResult;
|
||||||
|
reviewFailureTypes?: ProductPlanReviewFailureType[];
|
||||||
|
reviewFailureReason?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
overdueReason?: string;
|
overdueReason?: string;
|
||||||
actualStartAt?: string;
|
actualStartAt?: string;
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ test('aggregateWorkEffort calculates weighted progress by estimate', () => {
|
|||||||
assert.equal(result.progress, 75);
|
assert.equal(result.progress, 75);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aggregateWorkEffort preserves sub-half-hour AI estimates', () => {
|
||||||
|
const result = aggregateWorkEffort([
|
||||||
|
{ estimateHours: 0.2, actualHours: 0, progress: 50 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.estimateHours, 0.2);
|
||||||
|
assert.equal(result.progress, 50);
|
||||||
|
});
|
||||||
|
|
||||||
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
|
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
|
||||||
const result = aggregateWorkEffort([
|
const result = aggregateWorkEffort([
|
||||||
{ estimateHours: 0, actualHours: 0, progress: 50 },
|
{ estimateHours: 0, actualHours: 0, progress: 50 },
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ export function roundHalfHour(hours: number): number {
|
|||||||
return Math.round(hours * 2) / 2;
|
return Math.round(hours * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function roundEstimateHours(hours: number): number {
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
||||||
|
return Number(hours.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
|
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
|
||||||
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
|
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
|
||||||
|
|
||||||
const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
|
const estimateHours = roundEstimateHours(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
|
||||||
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
|
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
|
||||||
|
|
||||||
if (estimateHours <= 0) {
|
if (estimateHours <= 0) {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
id: createEntityId('tc'),
|
id: createEntityId('tc'),
|
||||||
caseNo: generateCaseNo(list),
|
caseNo: generateCaseNo(list),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
estimateHours: data.estimateHours ?? 0.5,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
} as TestCase);
|
} as TestCase);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||||
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||||
|
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||||
|
|
||||||
const MOCK_PLANS: VersionPlan[] = [];
|
const MOCK_PLANS: VersionPlan[] = [];
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ interface VersionPlanState {
|
|||||||
fetchPlans: () => Promise<void>;
|
fetchPlans: () => Promise<void>;
|
||||||
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||||
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
|
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
|
||||||
completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string };
|
completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string };
|
||||||
deletePlan: (id: string) => void;
|
deletePlan: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
- ❓ 注释含糊无法转化
|
- ❓ 注释含糊无法转化
|
||||||
- 对账报告先呈现给用户,用户审核后才执行写入
|
- 对账报告先呈现给用户,用户审核后才执行写入
|
||||||
|
|
||||||
|
4. **AI 预估不等于执行人预估**
|
||||||
|
- AI 只能输出 `aiEstimateHours`
|
||||||
|
- `estimateHours` 留给负责人/执行人确认后填写
|
||||||
|
- AI 不输出预计开始/截止时间,排期由负责人后续维护
|
||||||
|
|
||||||
4. **历史版本不强制存储**
|
4. **历史版本不强制存储**
|
||||||
- 系统不要求历史原型 URL 作为基准
|
- 系统不要求历史原型 URL 作为基准
|
||||||
- 拆偏问题靠"对账报告"暴露给用户,由人工补救
|
- 拆偏问题靠"对账报告"暴露给用户,由人工补救
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
- 当前版本关联需求列表(已被加进 Version 的 Requirement[])
|
- 当前版本关联需求列表(已被加进 Version 的 Requirement[])
|
||||||
- 版本成员清单(带角色:前端/后端/UI/测试)
|
- 版本成员清单(带角色:前端/后端/UI/测试)
|
||||||
|
|
||||||
**调用入口**:版本详情页 → 产品方案 Tab → 计划状态 = completed 后,按钮「AI 拆解任务和用例」
|
**调用入口**:版本详情页 → 产品方案 Tab → 计划状态 = completed 后,按钮「AI 拆解开发任务」或「AI 拆解测试用例」
|
||||||
|
|
||||||
**触发条件**:
|
**触发条件**:
|
||||||
- 当前版本至少有 1 条 type=product 的 VersionPlan 处于 `completed` 且 resultUrl 非空(提交了原型)
|
- 当前版本至少有 1 条 type=product 的 VersionPlan 处于 `completed` 且 resultUrl 非空(提交了原型)
|
||||||
@@ -53,12 +58,16 @@
|
|||||||
|
|
||||||
**输出**:
|
**输出**:
|
||||||
- 对账报告(结构化文本,含"完美对应/单边/含糊"三段)
|
- 对账报告(结构化文本,含"完美对应/单边/含糊"三段)
|
||||||
- DevTask 草案数组(每条带 `aiDraft: true` + `references[]`)
|
- DevTask 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
|
||||||
- TestCase 草案数组(每条带 `aiDraft: true` + `references[]`)
|
- TestCase 草案数组(每条带 `aiDraft: true` + `references[]` + `aiEstimateHours`)
|
||||||
|
- `target = dev_tasks` 时 `testCaseDrafts` 必须为空数组;`target = test_cases` 时 `devTaskDrafts` 必须为空数组
|
||||||
|
|
||||||
**写入**:
|
**写入**:
|
||||||
- 用户确认后,调用 `useDevTaskStore.createTask` 和 `useTestCaseStore.createTestCase`
|
- 用户确认后,调用 `useDevTaskStore.createTask` 和 `useTestCaseStore.createTestCase`
|
||||||
|
- 打开采纳弹窗前,前端先过滤当前版本已采纳过的重复 DevTask/TestCase 草案
|
||||||
- 写入字段中 `aiDraft: true`、`aiDraftAt: ISO时间戳`
|
- 写入字段中 `aiDraft: true`、`aiDraftAt: ISO时间戳`
|
||||||
|
- 写入 `aiEstimateHours`,不写入执行人预估 `estimateHours`
|
||||||
|
- DevTask 草案不写预计开始/截止时间,负责人后续排期时再填写
|
||||||
|
|
||||||
**权限**:
|
**权限**:
|
||||||
- 读:Version, Requirement, VersionPlan, Member
|
- 读:Version, Requirement, VersionPlan, Member
|
||||||
@@ -172,6 +181,7 @@ interface Reference {
|
|||||||
aiDraft?: boolean;
|
aiDraft?: boolean;
|
||||||
aiDraftAt?: string; // ISO 时间戳
|
aiDraftAt?: string; // ISO 时间戳
|
||||||
references?: Reference[];
|
references?: Reference[];
|
||||||
|
aiEstimateHours?: number; // AI 预估耗时,单位小时
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 输入预期
|
### Agent 输入预期
|
||||||
@@ -182,6 +192,7 @@ interface DecomposeInput {
|
|||||||
prototypeUrl: string; // 从产品方案 completed 计划的 resultUrl 派生
|
prototypeUrl: string; // 从产品方案 completed 计划的 resultUrl 派生
|
||||||
requirements: Requirement[]; // 当前版本所属项目下已采纳、可用于本次拆解的需求
|
requirements: Requirement[]; // 当前版本所属项目下已采纳、可用于本次拆解的需求
|
||||||
members: VersionMember[]; // 该版本参与人员(带角色)
|
members: VersionMember[]; // 该版本参与人员(带角色)
|
||||||
|
target?: 'all' | 'dev_tasks' | 'test_cases';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,12 +208,12 @@ interface DecomposeOutput {
|
|||||||
noteOnly: string[]; // 原型注释 ID 列表
|
noteOnly: string[]; // 原型注释 ID 列表
|
||||||
ambiguous: Array<{ noteId: string; reason: string }>;
|
ambiguous: Array<{ noteId: string; reason: string }>;
|
||||||
};
|
};
|
||||||
devTaskDrafts: Array<Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'isBlocked' | 'categoryId'> & { categoryCode: string; aiDraft: true; aiDraftAt: string; references: Reference[] }>;
|
devTaskDrafts: Array<{ title: string; description?: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
|
||||||
testCaseDrafts: Array<Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status' | 'categoryId'> & { categoryCode: string; aiDraft: true; aiDraftAt: string; references: Reference[] }>;
|
testCaseDrafts: Array<{ title: string; description: string; categoryCode: string; priority: Priority; aiEstimateHours: number; references: Reference[] }>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
要求:AI 不输出数据库 `categoryId`,只输出稳定 `categoryCode`。前端确认写入时按 `TaskCategory.code` 映射成 `categoryId`;映射失败时使用对应分组的默认类型兜底。
|
要求:AI 不输出数据库 `categoryId`,只输出稳定 `categoryCode`。前端确认写入时按 `TaskCategory.code` 映射成 `categoryId`;映射失败时使用对应分组的默认类型兜底。AI 不输出 `estimateHours`、预计开始或预计截止。
|
||||||
|
|
||||||
## 视觉规范
|
## 视觉规范
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,11 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBAC(Owner/Admin/Member/
|
|||||||
| DevTask | references | Reference[]? | 引用来源(需求/原型批注) |
|
| DevTask | references | Reference[]? | 引用来源(需求/原型批注) |
|
||||||
| DevTask | aiDraft | boolean? | AI 草案标记 |
|
| DevTask | aiDraft | boolean? | AI 草案标记 |
|
||||||
| DevTask | aiDraftAt | string? | AI 生成时间戳 |
|
| DevTask | aiDraftAt | string? | AI 生成时间戳 |
|
||||||
|
| DevTask | aiEstimateHours | number? | AI 预估耗时;执行人预估仍写 estimateHours |
|
||||||
| TestCase | references | Reference[]? | 同上 |
|
| TestCase | references | Reference[]? | 同上 |
|
||||||
| TestCase | aiDraft | boolean? | 同上 |
|
| TestCase | aiDraft | boolean? | 同上 |
|
||||||
| TestCase | aiDraftAt | string? | 同上 |
|
| TestCase | aiDraftAt | string? | 同上 |
|
||||||
|
| TestCase | aiEstimateHours | number? | AI 预估耗时;执行人预估仍写 estimateHours |
|
||||||
|
|
||||||
## 版本模块规则层(V2.2 设计约束)
|
## 版本模块规则层(V2.2 设计约束)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
- `testing` → 80%
|
- `testing` → 80%
|
||||||
- `submitted` → 100%
|
- `submitted` → 100%
|
||||||
|
|
||||||
**理由**:客观、不允许造假。需求级/版本级进度 = `Σ(estimateHours × 状态推导%) / Σ(estimateHours)`。
|
**理由**:客观、不允许造假。需求级/版本级进度 = `Σ(估算耗时 × 状态推导%) / Σ(估算耗时)`。估算耗时优先取执行人填写的 `estimateHours`,没有时才用 AI 草案的 `aiEstimateHours` 兜底。
|
||||||
|
|
||||||
## 2. 实际工时 — 由开始/完成时间戳计算
|
## 2. 实际工时 — 由开始/完成时间戳计算
|
||||||
|
|
||||||
@@ -269,3 +269,34 @@
|
|||||||
- 需求候选来源属于领域边界;统一 selector 能避免从需求池、项目需求、版本需求之间误取数据。
|
- 需求候选来源属于领域边界;统一 selector 能避免从需求池、项目需求、版本需求之间误取数据。
|
||||||
- AI 不应该猜数据库 id;稳定语义码能兼容管理员调整展示名称,也方便后续扩展更多模型。
|
- AI 不应该猜数据库 id;稳定语义码能兼容管理员调整展示名称,也方便后续扩展更多模型。
|
||||||
- 测试用例带任务类型后,才能知道测试覆盖范围,AI 拆解和人工创建的数据形状也一致。
|
- 测试用例带任务类型后,才能知道测试覆盖范围,AI 拆解和人工创建的数据形状也一致。
|
||||||
|
|
||||||
|
## 24. AI 预估和执行人预估分离
|
||||||
|
|
||||||
|
**问题**:AI 拆解写入 `estimateHours` 会让系统误以为负责人已经确认了工时和排期;同时 AI 会根据原型自动反推预计开始/截止,造成“计划已填”的错觉。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- AI 输出字段统一改为 `aiEstimateHours`,不再输出/写入 `estimateHours`。
|
||||||
|
- `estimateHours` 只表示执行人或负责人确认后的执行预估。
|
||||||
|
- 进度和统计口径优先使用 `estimateHours`;缺失时使用 `aiEstimateHours` 兜底;两者都没有时才从计划时间推导或按无估时处理。
|
||||||
|
- AI 采纳开发任务时不写预计开始/截止时间,由负责人后续填写计划排期。
|
||||||
|
- AI 重新拆解入口拆成“重新拆解开发任务”和“重新拆解测试用例”,请求通过 `target` 控制本次只生成哪类草案。
|
||||||
|
- AI 估时允许小于 0.5h,汇总时保留小数精度;实际耗时仍按状态时间戳计算并保持 0.5h 口径。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- AI 给的是建议,不是负责人承诺。
|
||||||
|
- 排期属于执行人计划,不能由拆解 Agent 代填。
|
||||||
|
- 独立重拆能避免修改开发任务时顺手覆盖测试用例,降低误采纳风险。
|
||||||
|
|
||||||
|
## 25. AI 重新拆解先过滤已采纳重复草案
|
||||||
|
|
||||||
|
**问题**:第一次 AI 拆解的 DevTask/TestCase 已经被采纳后,用户再次重新拆解时,模型可能再次输出同一批草案。若直接展示给用户采纳,会造成开发任务和测试用例重复。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- AI 返回结果后,前端在打开采纳弹窗前先做确定性去重。
|
||||||
|
- 开发任务比对范围是当前版本已关联需求下的现有 DevTask;测试用例比对范围是当前版本下的现有 TestCase。
|
||||||
|
- 重复判定使用:任务类型 code + 标题规范化 + 引用来源(需求 / 原型批注)一致。
|
||||||
|
- 重复草案不进入采纳列表,只在弹窗提示已过滤数量。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 去重不能只依赖模型提示,必须有系统规则兜底。
|
||||||
|
- 标题也参与签名,避免同一需求/QY 下不同真实工作项被误过滤。
|
||||||
|
|||||||
@@ -135,21 +135,22 @@ Workspace 页面(树筛选 + tab 筛选 + 已完成开关)
|
|||||||
↓
|
↓
|
||||||
4. 从当前项目已采纳需求中关联需求到本版本
|
4. 从当前项目已采纳需求中关联需求到本版本
|
||||||
↓
|
↓
|
||||||
5. 点击「AI 拆解任务和用例」按钮(位于产品方案 Tab)
|
5. 点击「AI 拆解开发任务」或「AI 拆解测试用例」按钮(位于产品方案 Tab)
|
||||||
↓
|
↓
|
||||||
6. Agent 从 product 类型的 completed 计划取 resultUrl 作为原型,
|
6. Agent 从 product 类型的 completed 计划取 resultUrl 作为原型,
|
||||||
抓取原型 + 关联需求 + 版本成员,输出对账报告 + 任务/用例草案
|
抓取原型 + 关联需求 + 版本成员,输出对账报告 + 任务/用例草案
|
||||||
↓
|
↓
|
||||||
7. 用户审核对账报告
|
7. 用户审核对账报告
|
||||||
|
├─ 系统先自动过滤已采纳过的重复 DevTask/TestCase 草案
|
||||||
├─ 报告全 ✅:直接确认写入
|
├─ 报告全 ✅:直接确认写入
|
||||||
├─ 报告有 ⚠️/❓:选择性放弃部分草案 / 补充信息后重跑
|
├─ 报告有 ⚠️/❓:选择性放弃部分草案 / 补充信息后重跑
|
||||||
└─ 报告全 ❓:放弃 AI 拆解,人工创建
|
└─ 报告全 ❓:放弃 AI 拆解,人工创建
|
||||||
↓
|
↓
|
||||||
8. 确认后,DevTask / TestCase 草案写入对应 Tab,标记 aiDraft: true
|
8. 确认后,DevTask / TestCase 草案写入对应 Tab,标记 aiDraft: true,并写入 aiEstimateHours
|
||||||
↓
|
↓
|
||||||
9. 团队成员在 DevTask Tab 看到紫色边的 AI 草案任务
|
9. 团队成员在 DevTask Tab 看到紫色边的 AI 草案任务
|
||||||
↓
|
↓
|
||||||
10. 任意成员编辑任务(改标题/描述/负责人/优先级/时间/分类),保存后 aiDraft 自动清除
|
10. 任意成员编辑任务(改标题/描述/负责人/优先级/时间/分类/执行预估),保存后 aiDraft 自动清除
|
||||||
(changeStatus / setBlocked 等用户主动操作也会清除)
|
(changeStatus / setBlocked 等用户主动操作也会清除)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,6 +180,11 @@ Workspace 页面(树筛选 + tab 筛选 + 已完成开关)
|
|||||||
- 多个组件共用的候选筛选规则进 selector/helper。
|
- 多个组件共用的候选筛选规则进 selector/helper。
|
||||||
- AI 输入输出字段先更新 `agent-spec.md` 和 shared type,再改 prompt/schema。
|
- AI 输入输出字段先更新 `agent-spec.md` 和 shared type,再改 prompt/schema。
|
||||||
|
|
||||||
|
AI 估时约束:
|
||||||
|
- `aiEstimateHours` 是 AI 建议工时,只能由 AI 拆解写入。
|
||||||
|
- `estimateHours` 是执行人预估,只能由人工创建/编辑或负责人确认排期时写入。
|
||||||
|
- 统计进度优先取 `estimateHours`,没有时取 `aiEstimateHours`,避免 AI 草案在未确认前失去统计权重。
|
||||||
|
|
||||||
版本模块新增规则:
|
版本模块新增规则:
|
||||||
|
|
||||||
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
|
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type AgentRole = 'frontend' | 'backend' | 'ui' | 'testing';
|
export type AgentRole = 'frontend' | 'backend' | 'ui' | 'testing';
|
||||||
|
|
||||||
|
export type AgentDecomposeTarget = 'all' | 'dev_tasks' | 'test_cases';
|
||||||
|
|
||||||
export type AgentTaskCategoryCode =
|
export type AgentTaskCategoryCode =
|
||||||
| 'frontend_development'
|
| 'frontend_development'
|
||||||
| 'frontend_interaction'
|
| 'frontend_interaction'
|
||||||
@@ -28,7 +30,7 @@ export interface AgentDevTaskDraft {
|
|||||||
description?: string;
|
description?: string;
|
||||||
categoryCode: AgentTaskCategoryCode;
|
categoryCode: AgentTaskCategoryCode;
|
||||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
estimateHours: number;
|
aiEstimateHours: number;
|
||||||
references: AgentReference[];
|
references: AgentReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export interface AgentTestCaseDraft {
|
|||||||
description: string;
|
description: string;
|
||||||
categoryCode: AgentTaskCategoryCode;
|
categoryCode: AgentTaskCategoryCode;
|
||||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
estimateHours: number;
|
aiEstimateHours: number;
|
||||||
references: AgentReference[];
|
references: AgentReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ export interface AgentDecomposeRequest {
|
|||||||
members: AgentDecomposeMember[];
|
members: AgentDecomposeMember[];
|
||||||
versionId: string;
|
versionId: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
target?: AgentDecomposeTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user