fix(ai): 拆解原型无需求ID任务

This commit is contained in:
Script Generator
2026-07-01 13:33:04 +08:00
parent 24e8635a94
commit 756621d366
23 changed files with 371 additions and 45 deletions

View File

@@ -44,8 +44,8 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
[requirements, requirementIds],
);
const versionTasks = useMemo(
() => tasks.filter((t) => requirementIds.includes(t.requirementId)),
[tasks, requirementIds],
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
[tasks, requirementIds, versionId],
);
const [title, setTitle] = useState('');
@@ -108,6 +108,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
})),
];
createTask({
versionId,
requirementId,
title: title.trim(),
description: description.trim() || undefined,

View File

@@ -28,6 +28,16 @@ interface Props {
readOnly?: boolean;
}
const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__';
function getDevTaskGroupKey(task: DevTask): string {
return task.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${task.requirementName || '未命名需求'}`;
}
function getNoRequirementGroupName(groupKey: string): string {
return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求';
}
export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) {
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
const { categories, fetchCategories } = useTaskCategoryStore();
@@ -42,8 +52,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
const versionTasks = useMemo(
() => tasks.filter((t) => reqIdSet.has(t.requirementId)),
[tasks, reqIdSet],
() => tasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
[tasks, reqIdSet, versionId],
);
const [filterAssignee, setFilterAssignee] = useState('');
@@ -107,9 +117,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
const groupedByReq = useMemo(() => {
const map = new Map<string, DevTask[]>();
for (const t of paged) {
const list = map.get(t.requirementId) || [];
const key = getDevTaskGroupKey(t);
const list = map.get(key) || [];
list.push(t);
map.set(t.requirementId, list);
map.set(key, list);
}
return map;
}, [paged]);
@@ -197,7 +208,9 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
</div>
) : (
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
const req = requirementMap.get(reqId);
const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX);
const req = isNoRequirementGroup ? undefined : requirementMap.get(reqId);
const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : '';
const reqProgress = calcGroupProgress(reqTasks);
return (
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
@@ -209,8 +222,12 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
)}
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
<span className="h-2 w-2 shrink-0" />
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code}>{req?.code}</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--ink)]">{req?.title}</span>
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={isNoRequirementGroup ? '无需求ID' : req?.code}>
{isNoRequirementGroup ? '无需求ID' : req?.code}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--ink)]">
{isNoRequirementGroup ? noRequirementName : req?.title}
</span>
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">{reqProgress}%</span>
</div>
</div>

View File

@@ -28,6 +28,16 @@ interface Props {
readOnly?: boolean;
}
const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__';
function getTestCaseGroupKey(testCase: { requirementId?: string; requirementName?: string }): string {
return testCase.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${testCase.requirementName || '未命名需求'}`;
}
function getNoRequirementGroupName(groupKey: string): string {
return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求';
}
export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) {
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
@@ -71,8 +81,8 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
);
const versionDevTasks = useMemo(
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
[devTasks, reqIdSet],
() => devTasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
[devTasks, reqIdSet, versionId],
);
const stats = calcTestProgress(activeRoundCases, versionBugs);
@@ -150,7 +160,7 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
const groupedByReq = useMemo(() => {
const map = new Map<string, typeof paged>();
for (const c of paged) {
const key = c.requirementId || '__none__';
const key = getTestCaseGroupKey(c);
const list = map.get(key) || [];
list.push(c);
map.set(key, list);
@@ -246,7 +256,9 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
</div>
) : (
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX);
const req = isNoRequirementGroup ? null : requirementMap.get(reqId);
const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : '';
const reqPassed = cases.filter((c) => c.status === 'passed').length;
const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null;
return (
@@ -259,8 +271,12 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
)}
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
<span className="h-2 w-2 shrink-0" />
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title || '未关联需求'}</span>
<span className="w-16 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={isNoRequirementGroup ? '无需求ID' : req?.code || '通用'}>
{isNoRequirementGroup ? '无需求ID' : req?.code || '通用'}
</span>
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">
{isNoRequirementGroup ? noRequirementName : req?.title || '未关联需求'}
</span>
{deliveryStatus && (
<span className={`inline-flex h-5 w-14 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap ${
deliveryStatus === 'submitted'

View File

@@ -180,7 +180,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
existingDevTasks: devTasks.filter((task) => task.versionId === version.id || (!task.versionId && requirementIdSet.has(task.requirementId))),
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
categories,
requirements: linkedReqs,

View File

@@ -10,6 +10,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption';
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
import type {
@@ -52,6 +53,7 @@ export function DecomposeReportModal({
const showTestDrafts = target !== 'dev_tasks';
const filteredDuplicateCount =
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
const prototypeOnlyGroups = data.report.prototypeOnly ?? [];
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
@@ -108,14 +110,15 @@ export function DecomposeReportModal({
if (!selectedDevIdx.has(i)) continue;
const draft = data.devTaskDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
createTask({
requirementId,
versionId: version.id,
requirementId: requirementFields.requirementId,
requirementName: requirementFields.requirementName,
title: draft.title,
description: draft.description,
categoryId,
@@ -148,14 +151,15 @@ export function DecomposeReportModal({
if (!selectedTcIdx.has(i)) continue;
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
createTestCase({
versionId: version.id,
requirementId: reqRef?.id,
requirementId: requirementFields.requirementId || undefined,
requirementName: requirementFields.requirementName,
title: draft.title,
description: draft.description,
categoryId,
@@ -269,17 +273,22 @@ export function DecomposeReportModal({
</div>
)}
{data.report.noteOnly.length > 0 && (
{prototypeOnlyGroups.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
<span className="text-[12px] font-medium text-blue-700"></span>
<span className="text-[12px] font-medium text-blue-700">ID分组</span>
</div>
<p className="text-[11px] text-blue-700 mb-1">
/
</p>
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</li>)}
<ul className="text-[12px] text-blue-800 ml-5 list-disc space-y-0.5">
{prototypeOnlyGroups.map((item, i) => (
<li key={i}>
<span className="font-medium">ID · {item.requirementName}</span>
<span>{item.noteIds.join(', ') || '无批注编号'} {item.taskCount} </span>
</li>
))}
</ul>
</div>
)}
@@ -336,6 +345,11 @@ export function DecomposeReportModal({
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{d.requirementName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
ID · {d.requirementName}
</span>
)}
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
@@ -402,6 +416,11 @@ export function DecomposeReportModal({
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{d.requirementName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
ID · {d.requirementName}
</span>
)}
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"

View File

@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveDecomposeDraftRequirementFields } from './ai-decompose-adoption';
const requirements = [
{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' },
{ id: 'req-2', code: 'REQ002', title: '排行榜' },
];
test('keeps prototype-only drafts out of formal requirements during adoption', () => {
const result = resolveDecomposeDraftRequirementFields(
{
requirementName: '预入职人员支持',
references: [{ type: 'prototype_note', id: 'QY0020', label: 'QY0020' }],
},
requirements,
);
assert.deepEqual(result, {
requirementId: '',
requirementName: '预入职人员支持',
});
});
test('maps formal requirement references by code during adoption', () => {
const result = resolveDecomposeDraftRequirementFields(
{
requirementName: '不应覆盖正式需求',
references: [
{ type: 'requirement', id: 'REQ002', label: 'REQ002 排行榜' },
{ type: 'prototype_note', id: 'QY0030', label: 'QY0030' },
],
},
requirements,
);
assert.deepEqual(result, {
requirementId: 'req-2',
requirementName: undefined,
});
});

View File

@@ -0,0 +1,46 @@
import type { AgentReference } from '@ftb/shared';
export interface DecomposeAdoptionRequirement {
id: string;
code: string;
title: string;
}
export interface DecomposeDraftRequirementInput {
requirementName?: string;
references: AgentReference[];
}
export interface DecomposeDraftRequirementFields {
requirementId: string;
requirementName?: string;
}
export function resolveDecomposeDraftRequirementFields(
draft: DecomposeDraftRequirementInput,
requirements: DecomposeAdoptionRequirement[],
): DecomposeDraftRequirementFields {
const reqRef = draft.references.find((ref) => ref.type === 'requirement');
if (reqRef) {
const requirement = requirements.find((item) => item.id === reqRef.id || item.code === reqRef.id);
return {
requirementId: requirement?.id ?? reqRef.id,
requirementName: undefined,
};
}
return {
requirementId: '',
requirementName: normalizePrototypeOnlyRequirementName(draft.requirementName, draft.references),
};
}
function normalizePrototypeOnlyRequirementName(
requirementName: string | undefined,
references: AgentReference[],
): string {
const trimmed = requirementName?.trim();
if (trimmed) return trimmed;
const note = references.find((ref) => ref.type === 'prototype_note');
return note?.label?.trim() || note?.id?.trim() || '未命名需求';
}

View File

@@ -30,7 +30,7 @@ const requirements = [{ id: 'req-1', code: 'REQ001' }];
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
return {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
...patch,

View File

@@ -10,7 +10,7 @@ import {
const response: AgentDecomposeResponse = {
ok: true,
result: {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},

View File

@@ -6,7 +6,7 @@ import type { AgentDecomposeResult } from '@ftb/shared';
function makeResult(): AgentDecomposeResult {
return {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
devTaskDrafts: [
{
title: '实现登录表单',

View File

@@ -15,7 +15,9 @@ export interface Reference {
export interface DevTask {
id: string;
taskNo: string;
versionId?: string;
requirementId: string;
requirementName?: string;
title: string;
description?: string;
categoryId: string;

View File

@@ -12,6 +12,7 @@ export interface TestCase {
caseNo: string;
versionId: string;
requirementId?: string;
requirementName?: string;
roundNo?: number;
sourceCaseId?: string;
title: string;
@@ -88,6 +89,7 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
versionId: testCase.versionId || '',
requirementId: testCase.requirementId,
requirementName: testCase.requirementName,
roundNo: getTestCaseRoundNo(testCase),
sourceCaseId: testCase.sourceCaseId,
title: testCase.title || `测试用例 ${index + 1}`,
@@ -165,6 +167,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
return {
versionId: source.versionId,
requirementId: source.requirementId,
requirementName: source.requirementName,
roundNo,
sourceCaseId: source.sourceCaseId ?? source.id,
title: source.title,

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { calcVersionProgress } from './version-progress';
import type { DevTask } from './dev-task';
import type { Requirement } from './requirement';
import type { VersionPlan } from './version-plan';
@@ -64,3 +65,28 @@ test('version progress uses explicit requirement coverage before legacy complete
assert.equal(progress, 0);
});
test('version progress includes no-requirement-id dev tasks by version id', () => {
const noReqTask = {
id: 'dev-no-req',
taskNo: 'DEV-001',
versionId: 'version-1',
requirementId: '',
requirementName: '预入职人员支持',
title: '实现预入职人员选择',
categoryId: 'frontend',
assigneeId: '',
priority: 'P1',
expectedStartAt: '',
expectedEndAt: '',
status: 'submitted',
isBlocked: false,
createdBy: 'AI',
createdAt: '2026-06-29T09:00:00.000Z',
updatedAt: '2026-06-29T10:00:00.000Z',
} as unknown as DevTask;
const progress = calcVersionProgress('version-1', [], [], [noReqTask], []);
assert.equal(progress, 100);
});

View File

@@ -20,7 +20,7 @@ export function calcVersionProgress(
const vPlans = plans.filter((p) => p.versionId === versionId);
const vReqs = requirements.filter((r) => r.versionId === versionId);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
const vTestCases = testCases.filter((c) => c.versionId === versionId);
const segments: number[] = [];
@@ -78,6 +78,11 @@ export function calcVersionProgress(
return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
function isDevTaskInVersion(task: DevTask, versionId: string, requirementIds: Set<string>): boolean {
if (task.versionId) return task.versionId === versionId;
return requirementIds.has(task.requirementId);
}
/**
* 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map
*/

View File

@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getWorkspacePendingCount } from './workspace-engine';
import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine';
import type { DevTask } from './dev-task';
test('getWorkspacePendingCount counts unfinished work items only', () => {
const count = getWorkspacePendingCount([
@@ -11,3 +12,37 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
assert.equal(count, 2);
});
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
const task = {
id: 'dev-no-req',
taskNo: 'DEV-001',
versionId: 'ver-1',
requirementId: '',
requirementName: '预入职人员支持',
title: '实现预入职人员选择',
categoryId: 'frontend',
assigneeId: '张三',
priority: 'P1',
expectedStartAt: '',
expectedEndAt: '',
status: 'todo',
isBlocked: false,
createdBy: 'AI',
createdAt: '2026-06-29T09:00:00.000Z',
updatedAt: '2026-06-29T09:00:00.000Z',
} as DevTask;
const items = aggregateWorkItems(
'张三',
[],
[task],
[],
[],
new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]),
new Map(),
);
assert.equal(items[0].versionId, 'ver-1');
assert.equal(items[0].versionName, 'V1.0');
});

View File

@@ -58,7 +58,7 @@ export function aggregateWorkItems(
// DevTasks: assigneeId === userName
devTasks.filter((t) => t.assigneeId === userName).forEach((t) => {
const versionId = requirementVersionMap.get(t.requirementId) ?? '';
const versionId = t.versionId || requirementVersionMap.get(t.requirementId) || '';
const ver = versionMap.get(versionId);
items.push({
id: t.id,

View File

@@ -363,3 +363,21 @@ test('buildXiaobaoWorkItems keeps all version work without current-user filterin
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
});
test('buildXiaobaoWorkItems uses direct version id for no-requirement dev tasks', () => {
const items = buildXiaobaoWorkItems({
devTasks: [
devTask({
id: 'dev-no-req',
versionId: 'ver-1',
requirementId: '',
requirementName: '预入职人员支持',
}),
],
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
requirementVersionMap: new Map(),
});
assert.equal(items[0].versionId, 'ver-1');
assert.equal(items[0].versionName, 'V1.0');
});

View File

@@ -92,7 +92,7 @@ export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkIt
}
for (const task of input.devTasks ?? []) {
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
const versionId = task.versionId || requirementVersionMap.get(task.requirementId) || '';
const version = versionMap.get(versionId);
items.push({
id: task.id,