Files
ftb-project-management/docs/superpowers/plans/2026-06-25-version-module-rules.md
2026-06-25 10:14:33 +08:00

37 KiB

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:

{
  "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:

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:

"test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test"
  • Step 4: Run existing web test harness

Run:

pnpm --filter web test

Expected: existing product-overview-persistence.test.ts compiles and passes.

  • Step 5: Commit
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:

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:

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:

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<CategoryGroup, string> = {
  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:

const LEGACY_CODE_BY_ID: Record<string, string> = {
  '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:

import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category';

In fetchCategories:

const cached = await loadStored();
if (cached) set({ categories: normalizeTaskCategories(cached) });

In addCategory, include code:

code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_'),
  • Step 6: Run tests

Run:

pnpm --filter web test
pnpm --filter web type-check

Expected: both pass.

  • Step 7: Commit
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:

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:

pnpm --filter web test

Expected: FAIL because requirement-selector.ts does not exist.

  • Step 3: Implement selector

Create apps/web/lib/requirement-selector.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:

pnpm --filter web test
pnpm --filter web type-check

Expected: both pass.

  • Step 5: Commit
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:

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>): 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:

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:

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:

import { getPlanCompletionState } from '@/lib/version-plan-workflow';

Change completePlan type:

completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string };

Update implementation:

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:

pnpm --filter web test
pnpm --filter web type-check

Expected: both pass.

  • Step 6: Commit
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:

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:

import { DEFAULT_TEST_CATEGORY_ID } from '@/lib/task-category';

In fetchTestCases:

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:

import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { getDefaultCategoryByGroup, getCategoriesByGroup } from '@/lib/task-category';

Add state:

const { categories } = useTaskCategoryStore();
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id);

Update submit guard:

const canSubmit = title.trim() && categoryId;

Pass categoryId into createTestCase.

Add field near priority:

<div>
  <label className="block text-[12px] text-[var(--ink-soft)] mb-1">任务类型 *</label>
  <select value={categoryId} onChange={(e) => setCategoryId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
    {testCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
  </select>
</div>
  • Step 4: Show category in TestCase list

Modify apps/web/components/test-case/TestCaseRow.tsx:

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:

<CategoryChip category={category} />

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:

import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { CategoryChip } from '@/components/dev-task/CategoryChip';

Find category:

const { categories } = useTaskCategoryStore();
const category = categories.find((c) => c.id === tc.categoryId);

Render in basic info:

<div className="col-span-2 flex items-center gap-2">
  <span className="text-[var(--ink-muted)]">任务类型:</span>
  <CategoryChip category={category} />
</div>
  • Step 6: Run verification

Run:

pnpm --filter web type-check

Expected: PASS.

  • Step 7: Commit
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:

import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';

In the plan tab render block:

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:

linkedRequirements={linkedReqs}
  • Step 2: Make requirement selection available for all plan types

Modify PlanFormModal in apps/web/components/version/PlanTab.tsx:

const showReqSelect = true;

Remove the old planType === 'product' || planType === 'ui' guard.

  • Step 3: Add select-all controls

Inside the linked requirement selection block, add:

<div className="mb-2 flex items-center justify-between">
  <span className="text-[11px] text-[var(--ink-muted)]">
    已选 {selectedReqs.size} / {linkedRequirements.length}
  </span>
  <div className="flex gap-2">
    <button type="button" onClick={() => setSelectedReqs(new Set(linkedRequirements.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline">全选</button>
    <button type="button" onClick={() => setSelectedReqs(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline">清空</button>
  </div>
</div>
  • Step 4: Make tasks required for research/product/ui

In PlanFormModal, change validation:

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:

import { getPlanCompletionState, canTogglePlanChecklist, canEditPlanRequirementCoverage } from '@/lib/version-plan-workflow';

Inside each plan render:

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:

{plan.status !== 'completed' && plan.status !== 'pending' && (
  <button
    type="button"
    onClick={() => setCompletingPlan(plan)}
    className="rounded-md p-1 text-emerald-600 hover:bg-emerald-50"
    title="完成"
  >
    <Check className="h-3.5 w-3.5" />
  </button>
)}

Replace green submit prompt with:

{plan.status !== 'completed' && completionState.canSubmitResult && (
  <div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
    <span className="text-[12px] text-green-700">已满足提交成果条件</span>
    <button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果</button>
  </div>
)}

Show missing reasons when in progress:

{plan.status === 'in_progress' && !completionState.canSubmitResult && (
  <p className="mt-2 text-[11px] text-[var(--ink-muted)]">
    还不能提交成果:{completionState.missingReasons.join('、')}
  </p>
)}
  • Step 6: Use workflow state in PlanDetailDrawer

Modify apps/web/components/version/PlanDetailDrawer.tsx similarly:

const completionState = getPlanCompletionState(plan);

Disable “提交完成” button unless completionState.canSubmitResult.

Show missing reasons under the disabled button.

When submitting result:

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:

pnpm --filter web test
pnpm --filter web type-check

Expected: both pass.

  • Step 8: Commit
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:

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:
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:
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:
categoryCode: {
  type: 'string',
  enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
},
  • Update required arrays:
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:

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:

import { resolveCategoryIdFromCode, findCategoryByCode } from '@/lib/task-category';

For dev tasks:

const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');

For test cases:

const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');

Pass categoryId into createTestCase.

Display category label:

<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
  {findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>

Remove ROLE_LABEL[d.role] usage for AI dev drafts.

  • Step 5: Run verification

Run:

pnpm --filter server test
pnpm --filter server type-check
pnpm --filter web type-check

Expected: all pass.

  • Step 6: Commit
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:

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:

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:

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:

git commit -m "fix(版本模块): 修正规则接入验证问题"

If no fixes were needed, do not create an empty commit.