# Version Module Rules Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Unify version-plan completion rules, project-scoped requirement selection, task category semantics, TestCase task types, and AI category output. **Architecture:** Add small pure helper layers before touching UI: `requirement-selector.ts`, `version-plan-workflow.ts`, and stable `TaskCategory.code` mapping. UI components consume those helpers; stores keep current JSONB/AppData shape with compatibility normalization. **Tech Stack:** Next.js 14, React, Zustand, TypeScript, Node `node:test` for web pure-function tests, NestJS/Jest for server AI schema tests. --- ## File Structure - Create `apps/web/tsconfig.test.json`: emits web lib tests into `.tmp-test`. - Create `apps/web/scripts/run-node-tests.mjs`: runs compiled Node test files. - Modify `apps/web/package.json`: add `test` script. - Modify `apps/web/lib/task-category.ts`: add `code`, `testing` group, presets, normalization and mapping helpers. - Create `apps/web/lib/task-category.test.ts`: category normalization and mapping tests. - Create `apps/web/lib/requirement-selector.ts`: project-scoped adopted requirement selector. - Create `apps/web/lib/requirement-selector.test.ts`: selector and historical selection tests. - Create `apps/web/lib/version-plan-workflow.ts`: plan completion state helper. - Create `apps/web/lib/version-plan-workflow.test.ts`: completion rule tests. - Modify `apps/web/lib/test-case.ts`: add `categoryId`. - Modify `apps/web/stores/useTaskCategoryStore.ts`: normalize fetched categories. - Modify `apps/web/stores/useTestCaseStore.ts`: backfill missing `categoryId`. - Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: required task type field. - Modify `apps/web/components/test-case/TestCaseTab.tsx`: category map and row prop. - Modify `apps/web/components/test-case/TestCaseRow.tsx`: show category chip. - Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show category. - Modify `apps/web/components/version/PlanTab.tsx`: use plan workflow and requirement selector UI. - Modify `apps/web/components/version/PlanDetailDrawer.tsx`: use plan workflow for completion. - Modify `apps/web/app/versions/[id]/page.tsx`: pass project-scoped requirement candidates to plan tabs. - Modify `apps/web/components/version/DecomposeReportModal.tsx`: map/display `categoryCode`. - Modify `packages/shared/src/agent.ts`: add `categoryCode` to dev/test drafts. - Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require `categoryCode` in tool schema and prompt. - Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert schema accepts category codes. --- ### Task 1: Add Web Pure-Function Test Harness **Files:** - Create: `apps/web/tsconfig.test.json` - Create: `apps/web/scripts/run-node-tests.mjs` - Modify: `apps/web/package.json` - [ ] **Step 1: Add test TypeScript config** Create `apps/web/tsconfig.test.json`: ```json { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": ".tmp-test", "rootDir": ".", "jsx": "react-jsx", "types": ["node"] }, "include": ["lib/**/*.ts"], "exclude": ["node_modules", ".next", ".tmp-test"] } ``` - [ ] **Step 2: Add Node test runner script** Create `apps/web/scripts/run-node-tests.mjs`: ```js import { readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; function collectTestFiles(dir) { const out = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) out.push(...collectTestFiles(full)); if (stat.isFile() && full.endsWith('.test.js')) out.push(full); } return out; } const root = process.argv[2] || '.tmp-test'; const files = collectTestFiles(root); if (files.length === 0) { console.error(`No compiled .test.js files found under ${root}`); process.exit(1); } const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' }); process.exit(result.status ?? 1); ``` - [ ] **Step 3: Add `test` script to web package** Modify `apps/web/package.json` scripts: ```json "test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test" ``` - [ ] **Step 4: Run existing web test harness** Run: ```bash pnpm --filter web test ``` Expected: existing `product-overview-persistence.test.ts` compiles and passes. - [ ] **Step 5: Commit** ```bash git add apps/web/package.json apps/web/tsconfig.test.json apps/web/scripts/run-node-tests.mjs git commit -m "test(web): 增加纯函数测试脚本" ``` --- ### Task 2: Extend TaskCategory With Stable Codes **Files:** - Modify: `apps/web/lib/task-category.ts` - Modify: `apps/web/stores/useTaskCategoryStore.ts` - Create: `apps/web/lib/task-category.test.ts` - [ ] **Step 1: Write category mapping tests** Create `apps/web/lib/task-category.test.ts`: ```ts import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_TEST_CATEGORY_ID, PRESET_CATEGORIES, findCategoryByCode, getDefaultCategoryByGroup, normalizeTaskCategories, resolveCategoryIdFromCode, } from './task-category'; test('preset categories include stable codes and testing group', () => { assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing')); assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0)); }); test('normalizes legacy categories without code', () => { const normalized = normalizeTaskCategories([ { id: 'cat-1', name: '前端开发', group: 'development', sortOrder: 1, isSystem: true } as any, ]); assert.equal(normalized[0].code, 'frontend_development'); }); test('resolves category id from stable code', () => { const id = resolveCategoryIdFromCode(PRESET_CATEGORIES, 'test_functional', 'testing'); assert.equal(id, DEFAULT_TEST_CATEGORY_ID); }); test('falls back to default group category for unknown code', () => { const category = getDefaultCategoryByGroup(PRESET_CATEGORIES, 'testing'); assert.equal(resolveCategoryIdFromCode(PRESET_CATEGORIES, 'unknown_code', 'testing'), category.id); }); test('finds category by code', () => { assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口'); }); ``` - [ ] **Step 2: Run test and confirm failure** Run: ```bash pnpm --filter web test ``` Expected: FAIL because helper exports do not exist yet. - [ ] **Step 3: Update category type and presets** Modify `apps/web/lib/task-category.ts`: ```ts export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other'; export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional'; export interface TaskCategory { id: string; code: string; name: string; group: CategoryGroup; color?: string; sortOrder: number; isSystem: boolean; } export const CATEGORY_GROUP_LABEL: Record = { development: '开发', testing: '测试', implementation: '实施', other: '其他', }; export const PRESET_CATEGORIES: TaskCategory[] = [ { id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true }, { id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true }, { id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 3, isSystem: true }, { id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true }, { 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-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 }, ]; ``` - [ ] **Step 4: Add normalization helpers** Add to `apps/web/lib/task-category.ts`: ```ts const LEGACY_CODE_BY_ID: Record = { 'cat-1': 'frontend_development', 'cat-2': 'backend_development', 'cat-3': 'database_schema', 'cat-4': 'api_integration', 'cat-5': 'data_processing', 'cat-6': 'implementation_support', }; function slugifyCategoryName(name: string): string { return name .trim() .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_') .replace(/^_+|_+$/g, '') || `category_${Date.now()}`; } export function normalizeTaskCategory(category: any, index = 0): TaskCategory { const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id); return { ...category, code: category?.code || LEGACY_CODE_BY_ID[category?.id] || preset?.code || slugifyCategoryName(category?.name || `category_${index}`), group: category?.group || 'other', sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : index + 1, isSystem: Boolean(category?.isSystem), } as TaskCategory; } export function normalizeTaskCategories(categories: any[]): TaskCategory[] { const merged = [...(Array.isArray(categories) ? categories : [])]; for (const preset of PRESET_CATEGORIES) { if (!merged.some((c) => c.id === preset.id)) merged.push(preset); } return merged.map((c, index) => normalizeTaskCategory(c, index)); } export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined { if (!code) return undefined; return categories.find((c) => c.code === code); } export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory { return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0]; } export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string { return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id; } ``` - [ ] **Step 5: Normalize task category store data** Modify `apps/web/stores/useTaskCategoryStore.ts`: ```ts import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category'; ``` In `fetchCategories`: ```ts const cached = await loadStored(); if (cached) set({ categories: normalizeTaskCategories(cached) }); ``` In `addCategory`, include `code`: ```ts code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_'), ``` - [ ] **Step 6: Run tests** Run: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 7: Commit** ```bash git add apps/web/lib/task-category.ts apps/web/lib/task-category.test.ts apps/web/stores/useTaskCategoryStore.ts git commit -m "feat(任务类型): 增加稳定分类语义码" ``` --- ### Task 3: Add Project-Scoped Requirement Selector **Files:** - Create: `apps/web/lib/requirement-selector.ts` - Create: `apps/web/lib/requirement-selector.test.ts` - [ ] **Step 1: Write selector tests** Create `apps/web/lib/requirement-selector.test.ts`: ```ts import test from 'node:test'; import assert from 'node:assert/strict'; import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector'; import type { Requirement } from './requirement'; const base = { description: '', productId: 'product-1', sourceType: 'internal', sourceTarget: '', platforms: [], typeId: '', priority: 'P2', effort: 'M', creator: 'tester', createdAt: '2026-06-25', } as const; function req(id: string, projectId: string, status: Requirement['status']): Requirement { return { ...base, id, code: id.toUpperCase(), title: `Requirement ${id}`, projectId, status, } as Requirement; } test('returns only adopted requirements from current project', () => { const result = getProjectAdoptedRequirementCandidates([ req('r1', 'project-1', 'adopted'), req('r2', 'project-1', 'pending_review'), req('r3', 'project-2', 'adopted'), ], 'project-1'); assert.deepEqual(result.map((r) => r.id), ['r1']); }); test('keeps historical selected requirements as non-candidate options', () => { const options = mergeSelectedRequirementOptions( [req('r1', 'project-1', 'adopted')], [req('r2', 'project-1', 'developing')], ['r1', 'r2'], ); assert.equal(options.find((o) => o.id === 'r2')?.isHistorical, true); }); ``` - [ ] **Step 2: Run test and confirm failure** Run: ```bash pnpm --filter web test ``` Expected: FAIL because `requirement-selector.ts` does not exist. - [ ] **Step 3: Implement selector** Create `apps/web/lib/requirement-selector.ts`: ```ts import type { Requirement } from './requirement'; export interface RequirementOption { id: string; code: string; title: string; productOwner?: string; status: Requirement['status']; isHistorical?: boolean; } export function toRequirementOption(requirement: Requirement, isHistorical = false): RequirementOption { return { id: requirement.id, code: requirement.code, title: requirement.title, productOwner: requirement.productOwner, status: requirement.status, isHistorical, }; } export function getProjectAdoptedRequirementCandidates(requirements: Requirement[], projectId: string): Requirement[] { return requirements .filter((r) => r.projectId === projectId && r.status === 'adopted') .sort((a, b) => a.code.localeCompare(b.code, 'zh-CN')); } export function mergeSelectedRequirementOptions( candidates: Requirement[], allRequirements: Requirement[], selectedIds: string[] = [], ): RequirementOption[] { const candidateOptions = candidates.map((r) => toRequirementOption(r)); const candidateIds = new Set(candidateOptions.map((r) => r.id)); const historical = selectedIds .filter((id) => !candidateIds.has(id)) .map((id) => allRequirements.find((r) => r.id === id)) .filter((r): r is Requirement => Boolean(r)) .map((r) => toRequirementOption(r, true)); return [...candidateOptions, ...historical]; } ``` - [ ] **Step 4: Run tests** Run: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 5: Commit** ```bash git add apps/web/lib/requirement-selector.ts apps/web/lib/requirement-selector.test.ts git commit -m "feat(需求): 统一项目已采纳需求选择器" ``` --- ### Task 4: Add VersionPlan Workflow Engine **Files:** - Create: `apps/web/lib/version-plan-workflow.ts` - Create: `apps/web/lib/version-plan-workflow.test.ts` - Modify: `apps/web/stores/useVersionPlanStore.ts` - [ ] **Step 1: Write workflow tests** Create `apps/web/lib/version-plan-workflow.test.ts`: ```ts import test from 'node:test'; import assert from 'node:assert/strict'; import { getPlanCompletionState, hasPlanResult } from './version-plan-workflow'; import type { VersionPlan } from './version-plan'; function plan(patch: Partial): VersionPlan { return { id: 'plan-1', versionId: 'version-1', type: 'product', title: '产品方案', owner: 'PM', startTime: '2026-06-25T09:00', endTime: '2026-06-25T18:00', status: 'in_progress', tasks: [{ id: 'task-1', title: '梳理方案', status: 'pending' }], linkedRequirementIds: ['r1'], completedRequirementIds: [], createdAt: '2026-06-25', addedBy: 'PM', ...patch, }; } test('does not allow result submission when subtasks are incomplete', () => { const state = getPlanCompletionState(plan({})); assert.equal(state.canSubmitResult, false); assert.ok(state.missingReasons.includes('子任务未全部完成')); }); test('requires product requirement coverage when linked requirements exist', () => { const state = getPlanCompletionState(plan({ tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], })); assert.equal(state.canSubmitResult, false); assert.ok(state.missingReasons.includes('关联需求未全部覆盖')); }); test('allows result submission after checklist and coverage are complete', () => { const state = getPlanCompletionState(plan({ tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], completedRequirementIds: ['r1'], })); assert.equal(state.canSubmitResult, true); assert.equal(state.canComplete, false); }); test('allows completion only after result exists', () => { const state = getPlanCompletionState(plan({ tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], completedRequirementIds: ['r1'], resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com/prototype', })); assert.equal(state.canComplete, true); }); test('research requires tasks and result but not requirement coverage', () => { const state = getPlanCompletionState(plan({ type: 'research', tasks: [{ id: 'task-1', title: '调研', status: 'completed' }], linkedRequirementIds: ['r1'], completedRequirementIds: [], resultType: 'file', resultTitle: '调研报告', resultFileName: 'report.pdf', resultFileData: 'data:application/pdf;base64,abc', })); assert.equal(state.canComplete, true); }); test('detects link and file result payloads', () => { assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com' }), true); assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true); assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false); }); ``` - [ ] **Step 2: Run test and confirm failure** Run: ```bash pnpm --filter web test ``` Expected: FAIL because `version-plan-workflow.ts` does not exist. - [ ] **Step 3: Implement workflow helper** Create `apps/web/lib/version-plan-workflow.ts`: ```ts import type { VersionPlan } from './version-plan'; export interface PlanResultPayload { resultType?: 'link' | 'file'; resultTitle?: string; resultUrl?: string; resultFileName?: string; resultFileData?: string; } export interface PlanCompletionState { checklistTotal: number; checklistCompleted: number; requirementTotal: number; requirementCompleted: number; hasResult: boolean; canSubmitResult: boolean; canComplete: boolean; missingReasons: string[]; } export function hasPlanResult(plan: PlanResultPayload): boolean { const hasTitle = Boolean(plan.resultTitle?.trim()); if (!hasTitle || !plan.resultType) return false; if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim()); return Boolean(plan.resultFileData || plan.resultFileName); } function requiresRequirementCoverage(plan: VersionPlan): boolean { return plan.type === 'product' || plan.type === 'ui'; } export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState { const tasks = plan.tasks ?? []; const checklistTotal = tasks.length; const checklistCompleted = tasks.filter((t) => t.status === 'completed').length; const linked = plan.linkedRequirementIds ?? []; const completed = new Set(plan.completedRequirementIds ?? []); const requirementTotal = linked.length; const requirementCompleted = linked.filter((id) => completed.has(id)).length; const missingReasons: string[] = []; if (checklistTotal === 0) missingReasons.push('缺少子任务'); if (checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成'); if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) { missingReasons.push('关联需求未全部覆盖'); } const canSubmitResult = missingReasons.length === 0; const hasResult = hasPlanResult(plan); if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果'); return { checklistTotal, checklistCompleted, requirementTotal, requirementCompleted, hasResult, canSubmitResult, canComplete: canSubmitResult && hasResult, missingReasons, }; } export function canTogglePlanChecklist(plan: VersionPlan, now: Date = new Date()): boolean { return plan.status === 'in_progress' || (plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= now); } export function canEditPlanRequirementCoverage(plan: VersionPlan, now: Date = new Date()): boolean { return canTogglePlanChecklist(plan, now); } ``` - [ ] **Step 4: Guard store completion** Modify `apps/web/stores/useVersionPlanStore.ts`: ```ts import { getPlanCompletionState } from '@/lib/version-plan-workflow'; ``` Change `completePlan` type: ```ts completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string }; ``` Update implementation: ```ts completePlan: (id, result) => { let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' }; const plans = get().plans.map((p) => { if (p.id !== id) return p; const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() }; const state = getPlanCompletionState(next); if (!state.canComplete) { response = { ok: false, message: state.missingReasons.join('、') || '计划未满足完成条件' }; return p; } response = { ok: true }; return next; }); set({ plans }); saveStored(plans); return response; }, ``` - [ ] **Step 5: Run tests** Run: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 6: Commit** ```bash git add apps/web/lib/version-plan-workflow.ts apps/web/lib/version-plan-workflow.test.ts apps/web/stores/useVersionPlanStore.ts git commit -m "feat(版本计划): 增加完成规则引擎" ``` --- ### Task 5: Add Task Type to TestCase **Files:** - Modify: `apps/web/lib/test-case.ts` - Modify: `apps/web/stores/useTestCaseStore.ts` - Modify: `apps/web/components/test-case/TestCaseCreateModal.tsx` - Modify: `apps/web/components/test-case/TestCaseTab.tsx` - Modify: `apps/web/components/test-case/TestCaseRow.tsx` - Modify: `apps/web/components/test-case/TestCaseDetailDrawer.tsx` - [ ] **Step 1: Add `categoryId` to TestCase type** Modify `apps/web/lib/test-case.ts`: ```ts export interface TestCase { id: string; caseNo: string; versionId: string; requirementId?: string; title: string; description?: string; categoryId: string; priority: Priority; assigneeId?: string; status: TestCaseStatus; // existing fields stay unchanged } ``` - [ ] **Step 2: Backfill old TestCase data** Modify `apps/web/stores/useTestCaseStore.ts`: ```ts import { DEFAULT_TEST_CATEGORY_ID } from '@/lib/task-category'; ``` In `fetchTestCases`: ```ts if (cached) { set({ testCases: cached.map((c) => ({ ...c, categoryId: c.categoryId || DEFAULT_TEST_CATEGORY_ID, })), }); } ``` - [ ] **Step 3: Add task type selector to TestCase create modal** Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: ```ts import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { getDefaultCategoryByGroup, getCategoriesByGroup } from '@/lib/task-category'; ``` Add state: ```ts const { categories } = useTaskCategoryStore(); const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]); const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id); ``` Update submit guard: ```ts const canSubmit = title.trim() && categoryId; ``` Pass `categoryId` into `createTestCase`. Add field near priority: ```tsx
``` - [ ] **Step 4: Show category in TestCase list** Modify `apps/web/components/test-case/TestCaseRow.tsx`: ```ts import type { TaskCategory } from '@/lib/task-category'; import { CategoryChip } from '@/components/dev-task/CategoryChip'; interface Props { testCase: TestCase; category?: TaskCategory; bugCount: number; onClick?: () => void; } ``` Render: ```tsx ``` Modify `apps/web/components/test-case/TestCaseTab.tsx` to fetch categories and pass `categoryMap.get(tc.categoryId)`. - [ ] **Step 5: Show category in TestCase drawer** Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: ```ts import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { CategoryChip } from '@/components/dev-task/CategoryChip'; ``` Find category: ```ts const { categories } = useTaskCategoryStore(); const category = categories.find((c) => c.id === tc.categoryId); ``` Render in basic info: ```tsx
任务类型:
``` - [ ] **Step 6: Run verification** Run: ```bash pnpm --filter web type-check ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts apps/web/components/test-case/TestCaseCreateModal.tsx apps/web/components/test-case/TestCaseTab.tsx apps/web/components/test-case/TestCaseRow.tsx apps/web/components/test-case/TestCaseDetailDrawer.tsx git commit -m "feat(测试用例): 增加任务类型" ``` --- ### Task 6: Wire Requirement Selector and Plan Workflow Into Version UI **Files:** - Modify: `apps/web/app/versions/[id]/page.tsx` - Modify: `apps/web/components/version/PlanTab.tsx` - Modify: `apps/web/components/version/PlanDetailDrawer.tsx` - [ ] **Step 1: Pass project-scoped adopted requirements to PlanTab** Modify `apps/web/app/versions/[id]/page.tsx`: ```ts import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; ``` In the plan tab render block: ```ts const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId); const linkedReqs = projectAdoptedReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner, })); ``` Pass to every `PlanTab`: ```tsx linkedRequirements={linkedReqs} ``` - [ ] **Step 2: Make requirement selection available for all plan types** Modify `PlanFormModal` in `apps/web/components/version/PlanTab.tsx`: ```ts const showReqSelect = true; ``` Remove the old `planType === 'product' || planType === 'ui'` guard. - [ ] **Step 3: Add select-all controls** Inside the linked requirement selection block, add: ```tsx
已选 {selectedReqs.size} / {linkedRequirements.length}
``` - [ ] **Step 4: Make tasks required for research/product/ui** In `PlanFormModal`, change validation: ```ts if (tasks.length === 0) return; ``` Remove the `planType === 'research'` condition around the task checklist UI so every plan type can add subtasks. - [ ] **Step 5: Use workflow state in PlanTab** Import: ```ts import { getPlanCompletionState, canTogglePlanChecklist, canEditPlanRequirementCoverage } from '@/lib/version-plan-workflow'; ``` Inside each plan render: ```ts const completionState = getPlanCompletionState(plan); const canToggle = canTogglePlanChecklist(plan); const canEditCoverage = canEditPlanRequirementCoverage(plan); ``` Use `canToggle` for task checkbox disabled state. Use `canEditCoverage` for requirement checkbox disabled state. Remove this direct-complete button block entirely: ```tsx {plan.status !== 'completed' && plan.status !== 'pending' && ( )} ``` Replace green submit prompt with: ```tsx {plan.status !== 'completed' && completionState.canSubmitResult && (
已满足提交成果条件
)} ``` Show missing reasons when in progress: ```tsx {plan.status === 'in_progress' && !completionState.canSubmitResult && (

还不能提交成果:{completionState.missingReasons.join('、')}

)} ``` - [ ] **Step 6: Use workflow state in PlanDetailDrawer** Modify `apps/web/components/version/PlanDetailDrawer.tsx` similarly: ```ts const completionState = getPlanCompletionState(plan); ``` Disable “提交完成” button unless `completionState.canSubmitResult`. Show missing reasons under the disabled button. When submitting result: ```ts const res = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined }); if (!res.ok) { alert(res.message || '计划未满足完成条件'); return; } ``` - [ ] **Step 7: Run verification** Run: ```bash pnpm --filter web test pnpm --filter web type-check ``` Expected: both pass. - [ ] **Step 8: Commit** ```bash git add apps/web/app/versions/[id]/page.tsx apps/web/components/version/PlanTab.tsx apps/web/components/version/PlanDetailDrawer.tsx git commit -m "feat(版本计划): 接入完成规则和需求全选" ``` --- ### Task 7: Upgrade AI Decompose Category Contract **Files:** - Modify: `packages/shared/src/agent.ts` - Modify: `apps/server/src/modules/ai/prompts/decompose.ts` - Modify: `apps/server/src/modules/ai/ai.service.spec.ts` - Modify: `apps/web/components/version/DecomposeReportModal.tsx` - [ ] **Step 1: Update shared agent types** Modify `packages/shared/src/agent.ts`: ```ts export type AgentTaskCategoryCode = | 'frontend_development' | 'frontend_interaction' | 'backend_development' | 'backend_api' | 'database_schema' | 'api_integration' | 'test_functional' | 'test_api' | 'test_exception' | 'test_compatibility' | 'data_processing' | 'implementation_support' | 'documentation'; export interface AgentDevTaskDraft { title: string; description?: string; categoryCode: AgentTaskCategoryCode; priority: 'P0' | 'P1' | 'P2' | 'P3'; estimateHours: number; references: AgentReference[]; } export interface AgentTestCaseDraft { title: string; description: string; categoryCode: AgentTaskCategoryCode; priority: 'P0' | 'P1' | 'P2' | 'P3'; references: AgentReference[]; } ``` Keep `AgentRole` only if other code still imports it; remove usage from dev draft after all references are updated. - [ ] **Step 2: Update server prompt and schema** Modify `apps/server/src/modules/ai/prompts/decompose.ts`: - Replace role instruction with category instruction: ```ts 4. 任务类型 - 每条开发任务和测试用例都必须输出 categoryCode - 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration - 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility - 不输出数据库 categoryId ``` - In `devTaskDrafts.items.properties`, replace `role` with: ```ts categoryCode: { type: 'string', enum: [ 'frontend_development', 'frontend_interaction', 'backend_development', 'backend_api', 'database_schema', 'api_integration', 'data_processing', 'implementation_support', 'documentation', ], }, ``` - In `testCaseDrafts.items.properties`, add: ```ts categoryCode: { type: 'string', enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'], }, ``` - Update required arrays: ```ts required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'] required: ['title', 'description', 'categoryCode', 'priority', 'references'] ``` - [ ] **Step 3: Update server schema test** Modify `apps/server/src/modules/ai/ai.service.spec.ts` or add a new test: ```ts import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; it('requires categoryCode for dev task and test case drafts', () => { const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts.items as any).required; const tcRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts.items as any).required; expect(devRequired).toContain('categoryCode'); expect(tcRequired).toContain('categoryCode'); }); ``` - [ ] **Step 4: Map categoryCode in DecomposeReportModal** Modify `apps/web/components/version/DecomposeReportModal.tsx`: ```ts import { resolveCategoryIdFromCode, findCategoryByCode } from '@/lib/task-category'; ``` For dev tasks: ```ts const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); ``` For test cases: ```ts const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); ``` Pass `categoryId` into `createTestCase`. Display category label: ```tsx {findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode} ``` Remove `ROLE_LABEL[d.role]` usage for AI dev drafts. - [ ] **Step 5: Run verification** Run: ```bash pnpm --filter server test pnpm --filter server type-check pnpm --filter web type-check ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add packages/shared/src/agent.ts apps/server/src/modules/ai/prompts/decompose.ts apps/server/src/modules/ai/ai.service.spec.ts apps/web/components/version/DecomposeReportModal.tsx git commit -m "feat(AI): 拆解结果输出任务类型语义码" ``` --- ### Task 8: Final Verification and Browser Smoke Check **Files:** - No source edits unless verification finds issues. - [ ] **Step 1: Run full focused verification** Run: ```bash pnpm --filter web test pnpm --filter web type-check pnpm --filter server test pnpm --filter server type-check ``` Expected: all pass. - [ ] **Step 2: Smoke check frontend routes** If dev server is already running, run: ```bash curl http://localhost:3000/versions curl http://localhost:3000/admin/categories ``` Expected: both return HTML and HTTP 200. - [ ] **Step 3: Manual browser checks** Open `http://localhost:3000/versions`, choose any existing version card, enter its detail page, and verify: - Research/Product/UI plan creation shows associated requirements and select-all. - Candidate requirements are only current project adopted requirements. - Plan cards do not show the top-right direct completion check. - Plan completion requires subtasks and result. - Test case create modal requires task type. - AI report modal shows category labels for dev tasks and test cases. - [ ] **Step 4: Inspect final diff** Run: ```bash git status --short git diff --stat ``` Expected: only intended implementation files changed since the last commit. - [ ] **Step 5: Commit final verification fixes if any** Only if Step 1-4 revealed small fixes, apply the focused patch, rerun the failed verification command, inspect `git diff --name-only`, and stage only the concrete files changed by that fix. Use the same commit message: ```bash git commit -m "fix(版本模块): 修正规则接入验证问题" ``` If no fixes were needed, do not create an empty commit.