feat(ai): 优化拆解目标和负责人推荐

This commit is contained in:
Script Generator
2026-06-29 16:19:52 +08:00
parent 0e27a4d4c5
commit 7f24e16491
19 changed files with 658 additions and 118 deletions

View File

@@ -12,6 +12,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { api } from '@/lib/api';
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
import { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target';
import { DecomposeReportModal } from './DecomposeReportModal';
import type {
AgentDecomposeTarget,
@@ -25,6 +26,12 @@ interface Props {
version: VersionWithContext;
}
interface DecomposeModalState {
result: AgentDecomposeResponse;
target: AgentDecomposeTarget;
dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number };
}
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
const STUCK_THRESHOLD_SEC = 240;
@@ -44,9 +51,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
const user = useAuthStore((s) => s.user);
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
const [modalState, setModalState] = useState<DecomposeModalState | null>(null);
const [tick, setTick] = useState(0);
const startedAtRef = useRef<number | null>(null);
const loading = activeTarget !== null;
@@ -55,6 +60,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
const persistStatus = plan.aiDecomposeStatus;
const persistError = plan.aiDecomposeError;
const persistAt = plan.aiDecomposeAt;
const persistTarget = plan.aiDecomposeTarget;
// 计算"已用时"
const elapsedSec = (() => {
@@ -72,6 +78,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
const isError = persistStatus === 'error' && !loading;
const wasCompleted = persistStatus === 'completed' && !loading;
const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null);
// tick 每秒更新一次,让"已用时"实时跳
useEffect(() => {
@@ -108,6 +115,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
aiDecomposeStatus: 'in_progress',
aiDecomposeBy: user?.name,
aiDecomposeAt: new Date().toISOString(),
aiDecomposeTarget: target,
aiDecomposeError: undefined,
});
@@ -134,24 +142,29 @@ export function AiDecomposeButton({ plan, version }: Props) {
if (!resp.ok) {
updatePlan(plan.id, {
aiDecomposeStatus: 'error',
aiDecomposeTarget: target,
aiDecomposeError: resp.error,
});
} else {
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
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,
setModalState({
result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) },
target,
dedupeSummary: {
removedDevTaskCount: deduped.removedDevTaskCount,
removedTestCaseCount: deduped.removedTestCaseCount,
},
});
setResultTarget(target);
updatePlan(plan.id, {
aiDecomposeStatus: 'completed',
aiDecomposeTarget: target,
aiDecomposeError: undefined,
});
}
@@ -159,6 +172,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
const msg = e?.message || '调用 AI 服务失败';
updatePlan(plan.id, {
aiDecomposeStatus: 'error',
aiDecomposeTarget: target,
aiDecomposeError: msg,
});
} finally {
@@ -175,10 +189,11 @@ export function AiDecomposeButton({ plan, version }: Props) {
<>
<span className="inline-flex items-center gap-1.5">
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
const showSpinner = isInProgress && runningTarget === target;
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
return (
<button
type="button"
key={target}
onClick={() => handleClick(target)}
disabled={isInProgress}
@@ -231,15 +246,15 @@ export function AiDecomposeButton({ plan, version }: Props) {
</span>
)}
{result && (
{modalState && (
<DecomposeReportModal
result={result}
result={modalState.result}
version={version}
plan={plan}
requirements={linkedReqs}
target={resultTarget}
dedupeSummary={dedupeSummary}
onClose={() => setResult(null)}
target={modalState.target}
dedupeSummary={modalState.dedupeSummary}
onClose={() => setModalState(null)}
/>
)}
</>

View File

@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
import type {
AgentDecomposeTarget,
AgentDecomposeResponse,
@@ -41,11 +42,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(data.devTaskDrafts.map((_, i) => i)),
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(data.testCaseDrafts.map((_, i) => i)),
new Set(showTestDrafts ? data.testCaseDrafts.map((_, i) => i) : []),
);
const [useRecommendedAssignees, setUseRecommendedAssignees] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
@@ -89,70 +91,72 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
return ref;
});
let devCount = 0;
for (let i = 0; i < data.devTaskDrafts.length; i++) {
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 categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
if (showDevDrafts) {
for (let i = 0; i < data.devTaskDrafts.length; i++) {
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 categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
createTask({
requirementId,
title: draft.title,
description: draft.description,
categoryId,
assigneeId: '',
reviewerId: undefined,
priority: draft.priority,
expectedStartAt: '',
expectedEndAt: '',
estimateHours: undefined,
aiEstimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
blockReason: undefined,
blockedById: undefined,
predecessorIds: undefined,
riskLevel: undefined,
delayReason: undefined,
overdueVersionReason: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
devCount++;
createTask({
requirementId,
title: draft.title,
description: draft.description,
categoryId,
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name ?? '' : '',
reviewerId: undefined,
priority: draft.priority,
expectedStartAt: '',
expectedEndAt: '',
estimateHours: undefined,
aiEstimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
blockReason: undefined,
blockedById: undefined,
predecessorIds: undefined,
riskLevel: undefined,
delayReason: undefined,
overdueVersionReason: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
}
}
let tcCount = 0;
for (let i = 0; i < data.testCaseDrafts.length; i++) {
if (!selectedTcIdx.has(i)) continue;
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
if (showTestDrafts) {
for (let i = 0; i < data.testCaseDrafts.length; i++) {
if (!selectedTcIdx.has(i)) continue;
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
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,
title: draft.title,
description: draft.description,
categoryId,
priority: draft.priority,
estimateHours: undefined,
aiEstimateHours,
assigneeId: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
tcCount++;
createTestCase({
versionId: version.id,
requirementId: reqRef?.id,
title: draft.title,
description: draft.description,
categoryId,
priority: draft.priority,
estimateHours: undefined,
aiEstimateHours,
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name : undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
}
}
setSubmitted(true);
@@ -162,6 +166,17 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
setTimeout(() => onClose(), 1500);
};
const visibleRecommendationCount = useMemo(() => {
const members = version.members ?? [];
const devCount = showDevDrafts
? data.devTaskDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
: 0;
const testCount = showTestDrafts
? data.testCaseDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
: 0;
return devCount + testCount;
}, [data.devTaskDrafts, data.testCaseDrafts, showDevDrafts, showTestDrafts, version.members]);
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
return (
@@ -281,7 +296,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.devTaskDrafts.map((d, i) => (
{data.devTaskDrafts.map((d, i) => {
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
return (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
@@ -304,6 +321,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
title={recommendation.reason ?? ''}
>
{recommendation.name}
</span>
)}
</div>
{d.description && (
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
@@ -320,7 +345,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</div>
</div>
</label>
))}
);
})}
</div>
)}
</section>
@@ -336,7 +362,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.testCaseDrafts.map((d, i) => (
{data.testCaseDrafts.map((d, i) => {
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
return (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
@@ -359,6 +387,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
title={recommendation.reason ?? ''}
>
{recommendation.name}
</span>
)}
</div>
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
{d.description}
@@ -375,7 +411,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</div>
</div>
</label>
))}
);
})}
</div>
)}
</section>
@@ -383,9 +420,21 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
<div className="text-[12px] text-[var(--ink-muted)]">
{showDevDrafts ? selectedDevIdx.size : 0} + {showTestDrafts ? selectedTcIdx.size : 0}
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t border-[var(--line)]">
<div className="flex flex-col gap-1">
<div className="text-[12px] text-[var(--ink-muted)]">
{showDevDrafts ? selectedDevIdx.size : 0} + {showTestDrafts ? selectedTcIdx.size : 0}
</div>
{visibleRecommendationCount > 0 && (
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
<input
type="checkbox"
checked={useRecommendedAssignees}
onChange={(event) => setUseRecommendedAssignees(event.target.checked)}
/>
<span>{visibleRecommendationCount}</span>
</label>
)}
</div>
<div className="flex gap-2">
<button

View File

@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveRecommendedAssignee } from './ai-assignee-recommendation';
import type { VersionMember } from './derive';
const members: VersionMember[] = [
{ name: 'Alice', role: 'frontend' },
{ name: 'Bob', role: 'testing' },
];
test('resolves an AI recommended assignee only when the name is a current version member', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'Alice',
recommendedAssigneeReason: 'Frontend implementation matches this member role.',
},
members,
);
assert.deepEqual(recommendation, {
name: 'Alice',
role: 'frontend',
reason: 'Frontend implementation matches this member role.',
});
});
test('ignores hallucinated recommended assignee names', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'Carol',
recommendedAssigneeReason: 'Model invented a person.',
},
members,
);
assert.equal(recommendation, undefined);
});
test('does not resolve case-mismatched member names', () => {
const recommendation = resolveRecommendedAssignee(
{
recommendedAssigneeName: 'alice',
recommendedAssigneeReason: 'Lowercase is not an exact member name.',
},
members,
);
assert.equal(recommendation, undefined);
});

View File

@@ -0,0 +1,29 @@
import type { VersionMember } from './derive';
export interface RecommendedAssigneeDraft {
recommendedAssigneeName?: string;
recommendedAssigneeReason?: string;
}
export interface ResolvedRecommendedAssignee {
name: string;
role: VersionMember['role'];
reason?: string;
}
export function resolveRecommendedAssignee(
draft: object,
members: VersionMember[] | undefined,
): ResolvedRecommendedAssignee | undefined {
const recommendation = draft as RecommendedAssigneeDraft;
if (!recommendation.recommendedAssigneeName) return undefined;
const member = (members ?? []).find((candidate) => candidate.name === recommendation.recommendedAssigneeName);
if (!member) return undefined;
const reason = recommendation.recommendedAssigneeReason?.trim();
return {
name: member.name,
role: member.role,
...(reason ? { reason } : {}),
};
}

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterDecomposeResultByTarget } from './ai-decompose-target';
import type { AgentDecomposeResult } from '@ftb/shared';
function makeResult(): AgentDecomposeResult {
return {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
devTaskDrafts: [
{
title: '实现登录表单',
categoryCode: 'frontend_development',
priority: 'P2',
aiEstimateHours: 0.5,
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
},
],
testCaseDrafts: [
{
title: '验证登录成功',
description: '输入正确账号密码后进入首页',
categoryCode: 'test_functional',
priority: 'P2',
aiEstimateHours: 0.2,
references: [{ type: 'requirement', id: 'REQ001', label: 'REQ001 登录' }],
},
],
};
}
test('keeps only dev task drafts for dev task decomposition target', () => {
const original = makeResult();
const filtered = filterDecomposeResultByTarget(original, 'dev_tasks');
assert.equal(filtered.devTaskDrafts.length, 1);
assert.equal(filtered.testCaseDrafts.length, 0);
assert.equal(original.testCaseDrafts.length, 1);
});
test('keeps only test case drafts for test case decomposition target', () => {
const filtered = filterDecomposeResultByTarget(makeResult(), 'test_cases');
assert.equal(filtered.devTaskDrafts.length, 0);
assert.equal(filtered.testCaseDrafts.length, 1);
});
test('keeps both draft groups for all decomposition target', () => {
const filtered = filterDecomposeResultByTarget(makeResult(), 'all');
assert.equal(filtered.devTaskDrafts.length, 1);
assert.equal(filtered.testCaseDrafts.length, 1);
});

View File

@@ -0,0 +1,14 @@
import type { AgentDecomposeResult, AgentDecomposeTarget } from '@ftb/shared';
export function filterDecomposeResultByTarget(
result: AgentDecomposeResult,
target: AgentDecomposeTarget = 'all',
): AgentDecomposeResult {
if (target === 'dev_tasks') {
return { ...result, testCaseDrafts: [] };
}
if (target === 'test_cases') {
return { ...result, devTaskDrafts: [] };
}
return result;
}

View File

@@ -23,4 +23,7 @@ test('defaults and clamps test case AI estimates below half an hour when appropr
assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
assert.equal(clampTestCaseAiEstimateHours('test_form_validation', 0.05), 0.1);
assert.equal(clampTestCaseAiEstimateHours('test_data_consistency', 2), 0.5);
assert.equal(getDefaultTestCaseAiEstimateHours('test_regression'), 0.25);
});

View File

@@ -16,16 +16,30 @@ const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
test_ui_interaction: { min: 0.1, max: 0.3, fallback: 0.2 },
test_form_validation: { min: 0.1, max: 0.25, fallback: 0.2 },
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
test_data_consistency: { min: 0.2, max: 0.5, fallback: 0.3 },
test_permission: { min: 0.2, max: 0.5, fallback: 0.3 },
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
test_boundary: { min: 0.1, max: 0.3, fallback: 0.2 },
test_state_flow: { min: 0.2, max: 0.5, fallback: 0.3 },
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
test_regression: { min: 0.15, max: 0.4, fallback: 0.25 },
};
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
test_ui_interaction: { min: 0.25, max: 2, fallback: 0.5 },
test_form_validation: { min: 0.25, max: 2, fallback: 0.5 },
test_api: { min: 0.25, max: 2, fallback: 0.5 },
test_data_consistency: { min: 0.25, max: 2, fallback: 0.5 },
test_permission: { min: 0.25, max: 2, fallback: 0.5 },
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
test_boundary: { min: 0.25, max: 2, fallback: 0.5 },
test_state_flow: { min: 0.25, max: 2, fallback: 0.5 },
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
test_regression: { min: 0.25, max: 2, fallback: 0.5 },
};
function roundToStep(hours: number, step: number): number {

View File

@@ -12,6 +12,17 @@ import {
test('preset categories include stable codes and testing group', () => {
assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
for (const code of [
'test_ui_interaction',
'test_form_validation',
'test_data_consistency',
'test_permission',
'test_boundary',
'test_state_flow',
'test_regression',
]) {
assert.ok(PRESET_CATEGORIES.some((c) => c.code === code && c.group === 'testing'), code);
}
assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
});

View File

@@ -27,9 +27,16 @@ export const PRESET_CATEGORIES: TaskCategory[] = [
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', color: '#06b6d4', sortOrder: 21, isSystem: true },
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', color: '#84cc16', sortOrder: 22, isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 23, isSystem: true },
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', color: '#10b981', sortOrder: 24, isSystem: true },
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', color: '#8b5cf6', sortOrder: 25, isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 26, isSystem: true },
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', color: '#f59e0b', sortOrder: 27, isSystem: true },
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', color: '#6366f1', sortOrder: 28, isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 29, isSystem: true },
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', color: '#64748b', sortOrder: 30, isSystem: true },
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },

View File

@@ -1,3 +1,5 @@
import type { AgentDecomposeTarget } from '@ftb/shared';
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
@@ -73,6 +75,7 @@ export interface VersionPlan {
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
aiDecomposeAt?: string;
aiDecomposeBy?: string;
aiDecomposeTarget?: AgentDecomposeTarget;
aiDecomposeError?: string;
}