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

1255 lines
37 KiB
Markdown

# 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<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`:
```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`:
```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>): 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
<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`:
```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
<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`:
```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
<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:
```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
<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:
```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' && (
<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:
```tsx
{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:
```tsx
{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:
```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
<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:
```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.