fix(ai): 拆解原型无需求ID任务
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user