From 5adc7759adef0cd4c536641bbe6b5e9710ffccb6 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Thu, 25 Jun 2026 15:21:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B9=B3=E5=8F=B0):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=8C=81=E4=B9=85=E5=8C=96=E5=92=8C?= =?UTF-8?q?AI=E6=8B=86=E8=A7=A3=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CLAUDE.md | 17 + apps/server/.env.example | 3 + apps/server/jest.config.js | 9 + apps/server/package.json | 2 + apps/server/prisma/schema.prisma | 9 + apps/server/src/app.module.ts | 5 +- apps/server/src/main.ts | 11 + .../src/modules/ai/ai-gateway.service.ts | 37 + apps/server/src/modules/ai/ai.controller.ts | 14 + apps/server/src/modules/ai/ai.module.ts | 13 + apps/server/src/modules/ai/ai.service.spec.ts | 155 ++ apps/server/src/modules/ai/ai.service.ts | 223 +++ .../src/modules/ai/dto/decompose.dto.ts | 46 + .../src/modules/ai/prompts/decompose.ts | 186 ++ .../src/modules/ai/prototype-context.spec.ts | 27 + .../src/modules/ai/prototype-context.ts | 72 + .../ai/providers/anthropic.provider.spec.ts | 106 ++ .../ai/providers/anthropic.provider.ts | 88 + .../src/modules/ai/providers/factory.ts | 14 + .../modules/ai/providers/openai.provider.ts | 79 + .../ai/providers/provider.interface.ts | 49 + .../modules/config/ai-config.controller.ts | 76 + .../src/modules/config/ai-config.service.ts | 200 +++ .../src/modules/config/config.module.ts | 10 + .../config/dto/update-ai-config.dto.ts | 44 + apps/server/src/modules/data/data-keys.ts | 18 + .../src/modules/data/data.controller.ts | 17 + apps/server/src/modules/data/data.module.ts | 9 + .../src/modules/data/data.service.spec.ts | 66 + apps/server/src/modules/data/data.service.ts | 32 + apps/server/src/prisma/prisma.service.ts | 19 +- apps/web/app/admin/ai-config/page.tsx | 503 ++++++ apps/web/app/login/page.tsx | 52 +- apps/web/app/projects/[id]/page.tsx | 20 +- apps/web/app/versions/[id]/page.tsx | 15 +- apps/web/app/versions/page.tsx | 11 +- apps/web/components/dev-task/DevTaskRow.tsx | 7 +- apps/web/components/layout/Sidebar.tsx | 3 +- .../components/version/AiDecomposeButton.tsx | 206 +++ .../version/DecomposeReportModal.tsx | 381 +++++ .../components/version/PlanDetailDrawer.tsx | 68 +- apps/web/components/version/PlanTab.tsx | 201 ++- .../version/VersionRequirementsTab.tsx | 16 +- apps/web/lib/api.ts | 25 +- .../lib/product-overview-persistence.test.ts | 12 + apps/web/lib/product-overview-persistence.ts | 3 + apps/web/lib/requirement-selector.test.ts | 49 + apps/web/lib/requirement-selector.ts | 42 + apps/web/lib/server-data.ts | 27 + apps/web/lib/task-category.test.ts | 38 + apps/web/lib/task-category.ts | 79 +- apps/web/lib/version-plan-workflow.test.ts | 85 + apps/web/lib/version-plan-workflow.ts | 76 + apps/web/lib/version-plan.ts | 5 + apps/web/package.json | 7 +- apps/web/scripts/run-node-tests.mjs | 25 + apps/web/stores/useAuthStore.ts | 95 +- apps/web/stores/useMemberStore.ts | 45 +- apps/web/stores/useOvertimeStore.ts | 38 +- apps/web/stores/useProductStore.ts | 59 +- apps/web/stores/useRequirementStore.ts | 59 +- apps/web/stores/useTaskCategoryStore.ts | 29 +- apps/web/stores/useTaskWorklogStore.ts | 22 +- apps/web/stores/useVersionPlanStore.ts | 43 +- apps/web/tsconfig.test.json | 15 + docs/glossary.md | 101 ++ .../2026-06-25-workflow-effort-engine.md | 1499 +++++++++++++++++ packages/shared/package.json | 6 +- packages/shared/src/agent.ts | 167 ++ packages/shared/src/index.ts | 1 + packages/shared/tsconfig.json | 4 +- pnpm-lock.yaml | 171 ++ 73 files changed, 5599 insertions(+), 368 deletions(-) create mode 100644 apps/server/jest.config.js create mode 100644 apps/server/src/modules/ai/ai-gateway.service.ts create mode 100644 apps/server/src/modules/ai/ai.controller.ts create mode 100644 apps/server/src/modules/ai/ai.module.ts create mode 100644 apps/server/src/modules/ai/ai.service.spec.ts create mode 100644 apps/server/src/modules/ai/ai.service.ts create mode 100644 apps/server/src/modules/ai/dto/decompose.dto.ts create mode 100644 apps/server/src/modules/ai/prompts/decompose.ts create mode 100644 apps/server/src/modules/ai/prototype-context.spec.ts create mode 100644 apps/server/src/modules/ai/prototype-context.ts create mode 100644 apps/server/src/modules/ai/providers/anthropic.provider.spec.ts create mode 100644 apps/server/src/modules/ai/providers/anthropic.provider.ts create mode 100644 apps/server/src/modules/ai/providers/factory.ts create mode 100644 apps/server/src/modules/ai/providers/openai.provider.ts create mode 100644 apps/server/src/modules/ai/providers/provider.interface.ts create mode 100644 apps/server/src/modules/config/ai-config.controller.ts create mode 100644 apps/server/src/modules/config/ai-config.service.ts create mode 100644 apps/server/src/modules/config/config.module.ts create mode 100644 apps/server/src/modules/config/dto/update-ai-config.dto.ts create mode 100644 apps/server/src/modules/data/data-keys.ts create mode 100644 apps/server/src/modules/data/data.controller.ts create mode 100644 apps/server/src/modules/data/data.module.ts create mode 100644 apps/server/src/modules/data/data.service.spec.ts create mode 100644 apps/server/src/modules/data/data.service.ts create mode 100644 apps/web/app/admin/ai-config/page.tsx create mode 100644 apps/web/components/version/AiDecomposeButton.tsx create mode 100644 apps/web/components/version/DecomposeReportModal.tsx create mode 100644 apps/web/lib/product-overview-persistence.test.ts create mode 100644 apps/web/lib/product-overview-persistence.ts create mode 100644 apps/web/lib/requirement-selector.test.ts create mode 100644 apps/web/lib/requirement-selector.ts create mode 100644 apps/web/lib/server-data.ts create mode 100644 apps/web/lib/task-category.test.ts create mode 100644 apps/web/lib/version-plan-workflow.test.ts create mode 100644 apps/web/lib/version-plan-workflow.ts create mode 100644 apps/web/scripts/run-node-tests.mjs create mode 100644 apps/web/tsconfig.test.json create mode 100644 docs/glossary.md create mode 100644 docs/superpowers/plans/2026-06-25-workflow-effort-engine.md create mode 100644 packages/shared/src/agent.ts diff --git a/.gitignore b/.gitignore index fdc2dea..cdb8f47 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ .DS_Store next-env.d.ts *.tsbuildinfo +apps/server/data/ diff --git a/CLAUDE.md b/CLAUDE.md index 2be623d..fba61d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## ⚠️ 必读文档(开始任何工作前先读) + +以下 4 个文档定义了项目的架构、决策、流程和路线图。**每次新会话开始时必须先读这些**,避免重新讨论已决定的方案: + +- `docs/architecture.md` — 整体架构、心智模型、关键设计原则 +- `docs/decisions.md` — 关键设计决策记录(含为什么这么做) +- `docs/workflow.md` — 工作流程、协作偏好、命名规范 +- `docs/roadmap.md` — V1/V2/V3 路线图和已完成清单 + +文档更新触发条件: +- **architecture.md**:新增核心模块、模块边界变更、系统架构调整、新增服务、数据流变化 +- **decisions.md**:方案评审完成、多方案比较后定方案、废弃旧方案、重要设计决策 +- **workflow.md**:新增业务流程、流程节点修改、状态机变更、审批流程变更 +- **roadmap.md**:Phase 完成、Milestone 完成、新增计划、优先级调整 + +不影响以上四类的小改动(UI 排版、bug fix、文案)不需要更新文档。 + ## Project Overview FTB 智能项目管理系统 — 一个集成 AI 能力的项目管理平台,核心层级:产品 → 项目 → 迭代 → 任务。 diff --git a/apps/server/.env.example b/apps/server/.env.example index 79c391b..6038bbc 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1,3 +1,6 @@ +# 后端 NestJS 环境变量 — 复制为 .env 后填入真实值 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm +# Anthropic API Key(V3.1 起 AI 拆解 Agent 必需) +# 申请:https://console.anthropic.com/ ANTHROPIC_API_KEY=sk-ant-xxx REDIS_URL=redis://localhost:6379 diff --git a/apps/server/jest.config.js b/apps/server/jest.config.js new file mode 100644 index 0000000..02c9583 --- /dev/null +++ b/apps/server/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + testEnvironment: 'node', +}; diff --git a/apps/server/package.json b/apps/server/package.json index 72b6838..3a24a0c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "db:studio": "prisma studio" }, "dependencies": { + "@anthropic-ai/sdk": "^0.27.0", "@ftb/shared": "workspace:*", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", @@ -24,6 +25,7 @@ "@prisma/client": "^5.15.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "openai": "^4.104.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0" }, diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 60b1e32..a7c1008 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -167,3 +167,12 @@ model ProjectMember { @@unique([projectId, userId]) @@map("project_members") } + +model AppData { + key String @id + value Json + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("app_data") +} diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 136c394..78cf077 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; import { ProductModule } from './modules/product/product.module'; import { RequirementModule } from './modules/requirement/requirement.module'; +import { AiModule } from './modules/ai/ai.module'; +import { ConfigModule } from './modules/config/config.module'; +import { DataModule } from './modules/data/data.module'; @Module({ - imports: [PrismaModule, ProductModule, RequirementModule], + imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule], controllers: [], providers: [], }) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 65896d0..7ce767a 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,7 +1,18 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; import { AppModule } from './app.module'; +// 加载 .env(Node v20+ 内置 loadEnvFile) +// 优先 cwd/.env,其次 dist 上一级(编译运行时 cwd 可能在 dist/) +for (const candidate of [resolve(process.cwd(), '.env'), resolve(__dirname, '../../.env'), resolve(__dirname, '../.env')]) { + if (existsSync(candidate) && typeof (process as any).loadEnvFile === 'function') { + (process as any).loadEnvFile(candidate); + break; + } +} + async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); diff --git a/apps/server/src/modules/ai/ai-gateway.service.ts b/apps/server/src/modules/ai/ai-gateway.service.ts new file mode 100644 index 0000000..8f6fde9 --- /dev/null +++ b/apps/server/src/modules/ai/ai-gateway.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { AiProvider } from './providers/provider.interface'; +import { createProvider } from './providers/factory'; +import { AiConfigService } from '../config/ai-config.service'; + +/** + * AI Gateway — 屏蔽底层 Provider,对上层 AiService 提供统一 callTool 接口 + * 激活的 Provider 切换 / Key 改变时自动重建客户端 + */ +@Injectable() +export class AiGatewayService { + private readonly logger = new Logger(AiGatewayService.name); + private cachedProvider: AiProvider | null = null; + private cachedKey: string = ''; + + constructor(private readonly config: AiConfigService) {} + + async getActiveProvider(): Promise { + const cfg = await this.config.getActiveProvider(); + if (!cfg) { + throw new Error('未配置任何 AI 提供商,请在「AI 配置」页面新增并激活一个'); + } + const sig = `${cfg.format}:${cfg.baseURL}:${cfg.apiKey}`; + if (!this.cachedProvider || this.cachedKey !== sig) { + this.cachedProvider = createProvider(cfg); + this.cachedKey = sig; + this.logger.log(`Provider 切换到 ${cfg.name} (${cfg.format})`); + } + return this.cachedProvider; + } + + async getActiveModel(): Promise { + const cfg = await this.config.getActiveProvider(); + if (!cfg) throw new Error('未配置任何 AI 提供商'); + return cfg.model; + } +} diff --git a/apps/server/src/modules/ai/ai.controller.ts b/apps/server/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..40a8933 --- /dev/null +++ b/apps/server/src/modules/ai/ai.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { AiService } from './ai.service'; +import { DecomposeDto } from './dto/decompose.dto'; +import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared'; + +@Controller('ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + + @Post('decompose') + async decompose(@Body() dto: DecomposeDto): Promise { + return this.aiService.decompose(dto); + } +} diff --git a/apps/server/src/modules/ai/ai.module.ts b/apps/server/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..a1f2a79 --- /dev/null +++ b/apps/server/src/modules/ai/ai.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; +import { AiGatewayService } from './ai-gateway.service'; +import { ConfigModule } from '../config/config.module'; + +@Module({ + imports: [ConfigModule], + controllers: [AiController], + providers: [AiService, AiGatewayService], + exports: [AiService], +}) +export class AiModule {} diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts new file mode 100644 index 0000000..89b8b48 --- /dev/null +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -0,0 +1,155 @@ +import { AiService } from './ai.service'; +import type { AiGatewayService } from './ai-gateway.service'; +import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; + +const missingToolUseMessage = + 'Anthropic 未通过 tool_use 返回结果;stop_reason=tool_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")'; + +describe('AiService', () => { + it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => { + const callTool = jest + .fn() + .mockRejectedValueOnce(new Error(missingToolUseMessage)) + .mockResolvedValueOnce({ + toolName: 'submit_decompose', + toolInput: { + report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] }, + devTaskDrafts: [], + testCaseDrafts: [], + }, + inputTokens: 100, + outputTokens: 20, + rawModel: 'claude-test', + }); + + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('claude-test'), + } as unknown as AiGatewayService; + + const service = new AiService(gateway); + (service as any).fetchPrototype = jest.fn().mockResolvedValue( + `${'无关内容'.repeat(5000)} QY0001:手机号登录。${'噪声内容'.repeat(5000)}`, + ); + + const result = await service.decompose({ + prototypeUrl: 'https://example.com/prototype', + requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }], + members: [{ name: '张三', role: 'frontend' }], + versionId: 'version-1', + planId: 'plan-1', + }); + + expect(result.ok).toBe(true); + expect(callTool).toHaveBeenCalledTimes(2); + const firstPrompt = callTool.mock.calls[0][0].userPrompt; + const retryPrompt = callTool.mock.calls[1][0].userPrompt; + expect(retryPrompt.length).toBeLessThan(firstPrompt.length); + expect(retryPrompt).toContain('重试'); + }); + + it('keeps shrinking prototype context when the first retry still returns no tool_use', async () => { + const callTool = jest + .fn() + .mockRejectedValueOnce(new Error(missingToolUseMessage)) + .mockRejectedValueOnce(new Error(missingToolUseMessage)) + .mockResolvedValueOnce({ + toolName: 'submit_decompose', + toolInput: { + report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] }, + devTaskDrafts: [], + testCaseDrafts: [], + }, + inputTokens: 100, + outputTokens: 20, + rawModel: 'claude-test', + }); + + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('claude-test'), + } as unknown as AiGatewayService; + + const service = new AiService(gateway); + (service as any).fetchPrototype = jest.fn().mockResolvedValue( + Array.from({ length: 30 }, (_, i) => `QY${String(i + 1).padStart(4, '0')}:${'内容'.repeat(300)}`).join('\n'), + ); + + const result = await service.decompose({ + prototypeUrl: 'https://example.com/prototype', + requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }], + members: [{ name: '张三', role: 'frontend' }], + versionId: 'version-1', + planId: 'plan-1', + }); + + expect(result.ok).toBe(true); + expect(callTool).toHaveBeenCalledTimes(3); + const firstPrompt = callTool.mock.calls[0][0].userPrompt; + const secondPrompt = callTool.mock.calls[1][0].userPrompt; + const thirdPrompt = callTool.mock.calls[2][0].userPrompt; + expect(secondPrompt.length).toBeLessThan(firstPrompt.length); + expect(thirdPrompt.length).toBeLessThan(secondPrompt.length); + }); + + it('retries prototype fetch before returning fetch failure', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = jest + .fn() + .mockRejectedValueOnce(new Error('fetch failed')) + .mockResolvedValueOnce({ + ok: true, + text: async () => 'QY0001:手机号登录', + }); + (globalThis as any).fetch = fetchMock; + + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_decompose', + toolInput: { + report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] }, + devTaskDrafts: [], + testCaseDrafts: [], + }, + inputTokens: 100, + outputTokens: 20, + rawModel: 'claude-test', + }); + + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('claude-test'), + } as unknown as AiGatewayService; + + const service = new AiService(gateway); + + try { + const result = await service.decompose({ + prototypeUrl: 'https://example.com/prototype', + requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }], + members: [{ name: '张三', role: 'frontend' }], + versionId: 'version-1', + planId: 'plan-1', + }); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(callTool).toHaveBeenCalledTimes(1); + } finally { + (globalThis as any).fetch = originalFetch; + } + }); + + it('requires categoryCode for dev task and test case drafts', () => { + const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required; + const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; + + expect(devRequired).toContain('categoryCode'); + expect(testCaseRequired).toContain('categoryCode'); + }); + + it('requires estimateHours for test case drafts', () => { + const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; + + expect(testCaseRequired).toContain('estimateHours'); + }); +}); diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..8c9cf5c --- /dev/null +++ b/apps/server/src/modules/ai/ai.service.ts @@ -0,0 +1,223 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiGatewayService } from './ai-gateway.service'; +import { + DECOMPOSE_SYSTEM_PROMPT, + DECOMPOSE_TOOL_NAME, + DECOMPOSE_TOOL_DESCRIPTION, + DECOMPOSE_TOOL_INPUT_SCHEMA, +} from './prompts/decompose'; +import { buildPrototypeContext } from './prototype-context'; +import type { + AgentDecomposeRequest, + AgentDecomposeResponse, + AgentDecomposeError, + AgentDecomposeResult, +} from '@ftb/shared'; + +const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; +const PROTOTYPE_FETCH_TIMEOUT_MS = 15000; +const PROTOTYPE_FETCH_ATTEMPTS = 3; +const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500; + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + + constructor(private readonly gateway: AiGatewayService) {} + + async decompose(req: AgentDecomposeRequest): Promise { + const startedAt = Date.now(); + + if (!req.requirements || req.requirements.length === 0) { + return { + ok: false, + error: '本版本未关联任何需求,无法进行原型拆解', + code: 'EMPTY_REQUIREMENTS', + }; + } + + let prototypeContent: string; + try { + prototypeContent = await this.fetchPrototype(req.prototypeUrl); + } catch (e: any) { + this.logger.error(`抓取原型失败: ${e.message}`); + return { + ok: false, + error: `无法访问原型链接:${e.message}`, + code: 'PROTOTYPE_FETCH_FAILED', + }; + } + + let provider; + let model: string; + try { + provider = await this.gateway.getActiveProvider(); + model = await this.gateway.getActiveModel(); + } catch (e: any) { + return { + ok: false, + error: e?.message || '未配置 AI 提供商', + code: 'NO_PROVIDER', + }; + } + + let toolResp; + for (let attemptIndex = 0; attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length; attemptIndex++) { + const contextChars = DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex]; + const prototypeContext = buildPrototypeContext(prototypeContent, contextChars); + const userPrompt = this.buildUserPrompt(req, prototypeContext.text, prototypeContext, attemptIndex > 0); + + try { + toolResp = await provider.callTool({ + systemPrompt: DECOMPOSE_SYSTEM_PROMPT, + userPrompt, + tool: { + name: DECOMPOSE_TOOL_NAME, + description: DECOMPOSE_TOOL_DESCRIPTION, + inputSchema: DECOMPOSE_TOOL_INPUT_SCHEMA, + }, + forceTool: true, + maxTokens: 16000, + model, + }); + break; + } catch (e: any) { + const canRetry = this.shouldRetryWithShortContext(e) && attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length - 1; + if (canRetry) { + this.logger.warn( + `AI 第 ${attemptIndex + 1} 次调用未返回 tool_use,使用 ${DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex + 1]} 字符原型上下文重试: ${e.message}`, + ); + continue; + } + + this.logger.error(`AI 调用失败: ${e.message}`); + return { + ok: false, + error: `AI 服务调用失败:${e.message}`, + code: 'API_ERROR', + }; + } + } + + if (!toolResp) { + return { + ok: false, + error: 'AI 服务调用失败:未获得工具结果', + code: 'UNKNOWN', + }; + } + + const result = toolResp.toolInput as AgentDecomposeResult; + if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) { + return { + ok: false, + error: 'AI 返回结构不符合预期', + code: 'PARSE_ERROR', + }; + } + + return { + ok: true, + result, + meta: { + model: toolResp.rawModel || model, + inputTokens: toolResp.inputTokens, + outputTokens: toolResp.outputTokens, + durationMs: Date.now() - startedAt, + }, + }; + } + + private async fetchPrototype(url: string): Promise { + if (!url || !url.startsWith('http')) { + throw new Error('原型链接无效'); + } + + let lastError: unknown; + for (let attempt = 1; attempt <= PROTOTYPE_FETCH_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PROTOTYPE_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const html = await res.text(); + return this.htmlToText(html); + } catch (e) { + lastError = e; + if (attempt < PROTOTYPE_FETCH_ATTEMPTS) { + this.logger.warn(`抓取原型失败,准备重试(${attempt}/${PROTOTYPE_FETCH_ATTEMPTS}): ${(e as Error).message}`); + await this.sleep(PROTOTYPE_FETCH_RETRY_DELAY_MS * attempt); + } + } finally { + clearTimeout(timeoutId); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + private htmlToText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\s+/g, ' ') + .trim(); + } + + private shouldRetryWithShortContext(error: any): boolean { + const msg = error?.message || ''; + return ( + msg.includes('Anthropic 未通过 tool_use 返回结果') && + (msg.includes('上游模型未返回任何内容') || msg.includes('stop_reason=tool_use')) + ); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private buildUserPrompt( + req: AgentDecomposeRequest, + prototypeContent: string, + context?: { noteCount: number; truncated: boolean }, + isRetry = false, + ): string { + const reqList = req.requirements + .map( + (r) => + `- id: ${r.id}, code: ${r.code}, title: ${r.title}${r.description ? `, desc: ${r.description}` : ''}`, + ) + .join('\n'); + + const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).join('\n'); + + return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型 + +URL: ${req.prototypeUrl} + +### 原型内容(已提取 QY 批注相关片段) + +- 识别到 QY 批注数量:${context?.noteCount ?? 0} +- 内容已截断:${context?.truncated ? '是' : '否'} + +${prototypeContent} + +## 本版本关联需求 + +${reqList || '(无)'} + +## 版本成员 + +${memberList || '(无)'} + +## 你的任务 + +按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。 +通过 tool 调用 submit_decompose 返回结果。`; + } +} diff --git a/apps/server/src/modules/ai/dto/decompose.dto.ts b/apps/server/src/modules/ai/dto/decompose.dto.ts new file mode 100644 index 0000000..06839bc --- /dev/null +++ b/apps/server/src/modules/ai/dto/decompose.dto.ts @@ -0,0 +1,46 @@ +import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class DecomposeReqMemberDto { + @IsString() + name!: string; + + @IsString() + role!: string; +} + +export class DecomposeReqRequirementDto { + @IsString() + id!: string; + + @IsString() + code!: string; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + description?: string; +} + +export class DecomposeDto { + @IsString() + prototypeUrl!: string; + + @IsString() + versionId!: string; + + @IsString() + planId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DecomposeReqRequirementDto) + requirements!: DecomposeReqRequirementDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DecomposeReqMemberDto) + members!: DecomposeReqMemberDto[]; +} diff --git a/apps/server/src/modules/ai/prompts/decompose.ts b/apps/server/src/modules/ai/prompts/decompose.ts new file mode 100644 index 0000000..42947a6 --- /dev/null +++ b/apps/server/src/modules/ai/prompts/decompose.ts @@ -0,0 +1,186 @@ +/** + * Prototype Decompose Agent — System Prompt + Tool Schema + * 详细规范见 docs/agent-spec.md + * + * 注意:这里的 Tool Schema 是格式无关的 JSON Schema, + * 由各 Provider 自己包成 Anthropic 的 input_schema 或 OpenAI 的 parameters + */ + +export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品方案拆解助手。 + +【职责】 +输入:原型 HTML/文档内容 + 关联需求列表 + 版本成员清单 +输出:开发任务草案 + 测试用例草案 + 对账报告 + +【硬规则】 + +1. 引用必须真实 + - 引用 requirement.id 必须在输入需求清单中 + - 引用 prototype_note 必须是输入原型里真实存在的 QY 编号 + - 不得编造 + +2. 任务来源限定 + - 只为以下情况拆任务: + a) 同时被需求和原型 QY 命中 + b) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充) + - 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出 + +3. 颗粒度(细颗粒) + - 一条 QY 涉及前后端时,前端任务和后端任务必须分开 + - 接口、数据库改动、前端 UI、前端交互、表单校验视为独立任务 + - 一条 QY 可能产出 3-6 个 DevTask + - 测试用例:每条 QY 至少 1 条功能用例 + 1 条边界用例 + +4. 任务类型 + - 每条开发任务和测试用例都必须输出 categoryCode + - 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration + - 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility + - 不输出数据库 categoryId + - 不输出推荐负责人(用户后续手填) + +5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格) + - 简单前端字段、文案、展示调整: 0.25-0.5h + - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h + - 拖拽排序并需要持久化接口: 1-1.5h + - 简单 CRUD 接口: 0.75-1.5h + - 数据库字段/索引调整: 0.5h + - 中等业务规则变更: 1.5-3h + - 简单功能测试用例执行: 0.25-0.5h + - API/异常/兼容性测试用例执行: 0.5-1h + - 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间 + +6. 标题:中文动词开头,简洁 + ✓ "在主题列表实现拖拽排序" + ✗ "关于 QY0010 主题拖拽排序的优化方案研究与实现" + +7. 不凭空补 + - 不要因为"通常应该有"就加"权限校验"任务 + - 只拆需求和原型上明确存在的内容 + +【对账报告要求】 +- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务) +- reqOnly: 需求里有但原型未见 → 列出需求 ID +- noteOnly: 原型里有但需求未提 → 列出 QY 编号 +- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因 + +【特殊情况】 +- 若原型内容里看不到任何 QY 编号或类似的批注编号 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组 +- 若原型完全无法解析 → 同上处理 + +【输出通道强制要求】 +- 第一块响应内容必须是 submit_decompose 的 tool_use +- 不要输出任何自然语言说明、Markdown、分析过程或前置文本 +- 即使无法解析原型,也必须通过 submit_decompose 返回空数组和 ambiguous 报告 + +通过 tool 调用 submit_decompose 工具返回结果。`; + +export const DECOMPOSE_TOOL_NAME = 'submit_decompose'; +export const DECOMPOSE_TOOL_DESCRIPTION = '提交原型拆解结果(开发任务草案 + 测试用例草案 + 对账报告)'; + +export const DECOMPOSE_TOOL_INPUT_SCHEMA = { + type: 'object', + properties: { + report: { + type: 'object', + properties: { + matched: { + type: 'array', + items: { + type: 'object', + properties: { + reqId: { type: 'string' }, + noteIds: { type: 'array', items: { type: 'string' } }, + taskCount: { type: 'integer' }, + }, + required: ['reqId', 'noteIds', 'taskCount'], + }, + }, + reqOnly: { type: 'array', items: { type: 'string' } }, + noteOnly: { type: 'array', items: { type: 'string' } }, + ambiguous: { + type: 'array', + items: { + type: 'object', + properties: { + noteId: { type: 'string' }, + reason: { type: 'string' }, + }, + required: ['noteId', 'reason'], + }, + }, + }, + required: ['matched', 'reqOnly', 'noteOnly', 'ambiguous'], + }, + devTaskDrafts: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + categoryCode: { + type: 'string', + enum: [ + 'frontend_development', + 'frontend_interaction', + 'backend_development', + 'backend_api', + 'database_schema', + 'api_integration', + 'data_processing', + 'implementation_support', + 'documentation', + ], + }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, + estimateHours: { type: 'number' }, + references: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['requirement', 'prototype_note'] }, + id: { type: 'string' }, + label: { type: 'string' }, + }, + required: ['type', 'id', 'label'], + }, + minItems: 1, + }, + }, + required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'], + }, + }, + testCaseDrafts: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + categoryCode: { + type: 'string', + enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'], + }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, + estimateHours: { type: 'number' }, + references: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['requirement', 'prototype_note'] }, + id: { type: 'string' }, + label: { type: 'string' }, + }, + required: ['type', 'id', 'label'], + }, + minItems: 1, + }, + }, + required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'], + }, + }, + }, + required: ['report', 'devTaskDrafts', 'testCaseDrafts'], +}; diff --git a/apps/server/src/modules/ai/prototype-context.spec.ts b/apps/server/src/modules/ai/prototype-context.spec.ts new file mode 100644 index 0000000..efe02df --- /dev/null +++ b/apps/server/src/modules/ai/prototype-context.spec.ts @@ -0,0 +1,27 @@ +import { buildPrototypeContext } from './prototype-context'; + +describe('buildPrototypeContext', () => { + it('keeps QY note snippets and drops unrelated long text', () => { + const longPrefix = '无关说明'.repeat(2000); + const longSuffix = '页面噪声'.repeat(2000); + const content = `${longPrefix} QY0007:支持手机号登录,需要验证码输入和倒计时。${longSuffix}`; + + const context = buildPrototypeContext(content, 800); + + expect(context.text).toContain('QY0007'); + expect(context.text).toContain('手机号登录'); + expect(context.text).not.toContain(longPrefix.slice(0, 200)); + expect(context.text.length).toBeLessThanOrEqual(900); + expect(context.noteCount).toBe(1); + }); + + it('merges overlapping QY snippets instead of duplicating the same content', () => { + const content = `${'P'.repeat(160)} QY0001 这里是第一个批注 ${'A'.repeat(180)} QY0002 这里是第二个批注 ${'B'.repeat(300)}`; + + const context = buildPrototypeContext(content, 1000); + + expect(context.text).toContain('QY0001'); + expect(context.text).toContain('QY0002'); + expect(context.text.length).toBeLessThan(800); + }); +}); diff --git a/apps/server/src/modules/ai/prototype-context.ts b/apps/server/src/modules/ai/prototype-context.ts new file mode 100644 index 0000000..2b7eb61 --- /dev/null +++ b/apps/server/src/modules/ai/prototype-context.ts @@ -0,0 +1,72 @@ +export interface PrototypeContext { + text: string; + noteCount: number; + truncated: boolean; +} + +const QY_NOTE_PATTERN = /QY\d{3,}/gi; +const MAX_NOTE_SNIPPETS = 40; + +export function buildPrototypeContext(content: string, maxChars = 12000): PrototypeContext { + const normalized = content.replace(/\s+/g, ' ').trim(); + const matches = Array.from(normalized.matchAll(QY_NOTE_PATTERN)); + + if (matches.length === 0) { + return { + text: normalized.slice(0, maxChars), + noteCount: 0, + truncated: normalized.length > maxChars, + }; + } + + const windowSize = Math.max(280, Math.floor(maxChars / Math.min(matches.length, 12))); + const ranges = matches.slice(0, MAX_NOTE_SNIPPETS) + .map((match) => { + const index = match.index ?? 0; + return { + start: Math.max(0, index - 120), + end: Math.min(normalized.length, index + windowSize), + }; + }) + .sort((a, b) => a.start - b.start); + + const mergedRanges: Array<{ start: number; end: number }> = []; + for (const range of ranges) { + const last = mergedRanges[mergedRanges.length - 1]; + if (!last || range.start > last.end) { + mergedRanges.push({ ...range }); + } else { + last.end = Math.max(last.end, range.end); + } + } + + const snippets: string[] = []; + let usedChars = 0; + let truncated = matches.length > MAX_NOTE_SNIPPETS; + for (const range of mergedRanges) { + const separatorLength = snippets.length > 0 ? '\n\n---\n\n'.length : 0; + const remaining = maxChars - usedChars - separatorLength; + if (remaining <= 0) { + truncated = true; + break; + } + + const snippet = normalized.slice(range.start, range.end).trim(); + if (snippet.length > remaining) { + snippets.push(snippet.slice(0, remaining).trim()); + usedChars = maxChars; + truncated = true; + break; + } + + snippets.push(snippet); + usedChars += separatorLength + snippet.length; + } + + const text = snippets.join('\n\n---\n\n'); + return { + text, + noteCount: matches.length, + truncated, + }; +} diff --git a/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts b/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts new file mode 100644 index 0000000..b179fe3 --- /dev/null +++ b/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts @@ -0,0 +1,106 @@ +import { AnthropicProvider } from './anthropic.provider'; + +const mockCreate = jest.fn(); + +jest.mock('@anthropic-ai/sdk', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + messages: { + create: mockCreate, + }, + })), +})); + +describe('AnthropicProvider', () => { + beforeEach(() => { + mockCreate.mockReset(); + }); + + it('reports stop reason and text preview when no tool_use block is returned', async () => { + mockCreate.mockResolvedValue({ + stop_reason: 'end_turn', + model: 'claude-test', + content: [ + { + type: 'text', + text: '我理解你的意思,但这里没有可识别的 QY 编号,所以我先用自然语言解释。', + }, + ], + usage: { + input_tokens: 12, + output_tokens: 34, + }, + }); + + const provider = new AnthropicProvider('test-key', 'https://example.test'); + + await expect( + provider.callTool({ + systemPrompt: '只通过工具返回', + userPrompt: '拆解这个原型', + tool: { + name: 'submit_decompose', + description: '提交拆解结果', + inputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + }, + forceTool: true, + maxTokens: 1000, + model: 'claude-test', + }), + ).rejects.toThrow( + /Anthropic 未通过 tool_use 返回结果.*stop_reason=end_turn.*content=text\("我理解你的意思/, + ); + }); + + it('disables parallel tool use when a specific tool is forced', async () => { + mockCreate.mockResolvedValue({ + stop_reason: 'tool_use', + model: 'claude-test', + content: [ + { + type: 'tool_use', + id: 'toolu_test', + name: 'submit_decompose', + input: { ok: true }, + }, + ], + usage: { + input_tokens: 12, + output_tokens: 34, + }, + }); + + const provider = new AnthropicProvider('test-key', 'https://example.test'); + + await provider.callTool({ + systemPrompt: '只通过工具返回', + userPrompt: '拆解这个原型', + tool: { + name: 'submit_decompose', + description: '提交拆解结果', + inputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + }, + forceTool: true, + maxTokens: 1000, + model: 'claude-test', + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: { + type: 'tool', + name: 'submit_decompose', + disable_parallel_tool_use: true, + }, + }), + ); + }); +}); diff --git a/apps/server/src/modules/ai/providers/anthropic.provider.ts b/apps/server/src/modules/ai/providers/anthropic.provider.ts new file mode 100644 index 0000000..e334171 --- /dev/null +++ b/apps/server/src/modules/ai/providers/anthropic.provider.ts @@ -0,0 +1,88 @@ +import Anthropic from '@anthropic-ai/sdk'; +import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface'; + +/** + * Anthropic 格式提供商(官方 API + 兼容 Anthropic 格式的中转站) + */ +export class AnthropicProvider implements AiProvider { + readonly format = 'anthropic' as const; + private client: Anthropic; + + constructor(apiKey: string, baseURL?: string) { + this.client = new Anthropic({ + apiKey, + ...(baseURL && baseURL !== 'https://api.anthropic.com' ? { baseURL } : {}), + }); + } + + async callTool(req: ToolCallRequest): Promise { + const toolChoice = req.forceTool + ? ({ + type: 'tool', + name: req.tool.name, + disable_parallel_tool_use: true, + } as any) + : { type: 'auto' as const }; + + const response = await this.client.messages.create({ + model: req.model, + max_tokens: req.maxTokens, + system: req.systemPrompt, + tools: [ + { + name: req.tool.name, + description: req.tool.description, + input_schema: req.tool.inputSchema, + }, + ], + tool_choice: toolChoice, + messages: [{ role: 'user', content: req.userPrompt }], + }); + + const toolUse = response.content.find( + (c): c is Anthropic.ToolUseBlock => c.type === 'tool_use', + ); + if (!toolUse) { + throw new Error(this.describeMissingToolUse(response)); + } + + return { + toolName: toolUse.name, + toolInput: toolUse.input as Record, + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + rawModel: response.model, + }; + } + + async ping(req: PingRequest): Promise { + const resp = await this.client.messages.create({ + model: req.model, + max_tokens: 16, + messages: [{ role: 'user', content: 'ping' }], + }); + return { + ok: true, + inputTokens: resp.usage.input_tokens, + outputTokens: resp.usage.output_tokens, + }; + } + + private describeMissingToolUse(response: Anthropic.Message): string { + const contentSummary = response.content + .map((block) => { + if (block.type === 'text') { + const text = block.text.replace(/\s+/g, ' ').slice(0, 160); + return `text("${text}")`; + } + return block.type; + }) + .join(', '); + + return [ + 'Anthropic 未通过 tool_use 返回结果', + `stop_reason=${response.stop_reason || 'unknown'}`, + `content=${contentSummary || 'empty'}`, + ].join(';'); + } +} diff --git a/apps/server/src/modules/ai/providers/factory.ts b/apps/server/src/modules/ai/providers/factory.ts new file mode 100644 index 0000000..7f57707 --- /dev/null +++ b/apps/server/src/modules/ai/providers/factory.ts @@ -0,0 +1,14 @@ +import type { AiProvider } from './provider.interface'; +import { AnthropicProvider } from './anthropic.provider'; +import { OpenAIProvider } from './openai.provider'; +import type { AiProviderConfig } from '@ftb/shared'; + +export function createProvider(config: AiProviderConfig): AiProvider { + if (config.format === 'anthropic') { + return new AnthropicProvider(config.apiKey, config.baseURL); + } + if (config.format === 'openai') { + return new OpenAIProvider(config.apiKey, config.baseURL); + } + throw new Error(`未支持的 provider format: ${(config as any).format}`); +} diff --git a/apps/server/src/modules/ai/providers/openai.provider.ts b/apps/server/src/modules/ai/providers/openai.provider.ts new file mode 100644 index 0000000..8925682 --- /dev/null +++ b/apps/server/src/modules/ai/providers/openai.provider.ts @@ -0,0 +1,79 @@ +import OpenAI from 'openai'; +import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface'; + +/** + * OpenAI 格式提供商(兼容 OpenAI Chat Completions 风格的中转站) + */ +export class OpenAIProvider implements AiProvider { + readonly format = 'openai' as const; + private client: OpenAI; + + constructor(apiKey: string, baseURL: string) { + this.client = new OpenAI({ + apiKey, + baseURL, + }); + } + + async callTool(req: ToolCallRequest): Promise { + const response = await this.client.chat.completions.create({ + model: req.model, + max_tokens: req.maxTokens, + messages: [ + { role: 'system', content: req.systemPrompt }, + { role: 'user', content: req.userPrompt }, + ], + tools: [ + { + type: 'function', + function: { + name: req.tool.name, + description: req.tool.description, + parameters: req.tool.inputSchema, + }, + }, + ], + tool_choice: req.forceTool + ? { type: 'function', function: { name: req.tool.name } } + : 'auto', + }); + + const choice = response.choices[0]; + if (!choice?.message?.tool_calls?.length) { + throw new Error('OpenAI 未通过 tool_calls 返回结果'); + } + + const tc = choice.message.tool_calls[0]; + if (tc.type !== 'function') { + throw new Error('OpenAI 返回的 tool_call 类型非 function'); + } + + let parsed: Record; + try { + parsed = JSON.parse(tc.function.arguments || '{}'); + } catch (e) { + throw new Error(`OpenAI tool_call arguments 不是合法 JSON: ${(e as Error).message}`); + } + + return { + toolName: tc.function.name, + toolInput: parsed, + inputTokens: response.usage?.prompt_tokens ?? 0, + outputTokens: response.usage?.completion_tokens ?? 0, + rawModel: response.model, + }; + } + + async ping(req: PingRequest): Promise { + const resp = await this.client.chat.completions.create({ + model: req.model, + max_tokens: 16, + messages: [{ role: 'user', content: 'ping' }], + }); + return { + ok: true, + inputTokens: resp.usage?.prompt_tokens ?? 0, + outputTokens: resp.usage?.completion_tokens ?? 0, + }; + } +} diff --git a/apps/server/src/modules/ai/providers/provider.interface.ts b/apps/server/src/modules/ai/providers/provider.interface.ts new file mode 100644 index 0000000..e49a4b3 --- /dev/null +++ b/apps/server/src/modules/ai/providers/provider.interface.ts @@ -0,0 +1,49 @@ +/** + * AI Provider 抽象接口 + * 屏蔽 Anthropic vs OpenAI 格式差异,对上层提供统一调用 + */ + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: any; // JSON Schema for tool input +} + +export interface ToolCallRequest { + systemPrompt: string; + userPrompt: string; + tool: ToolDefinition; + /** 必须强制走 tool(Anthropic: tool_choice / OpenAI: tool_choice) */ + forceTool: boolean; + maxTokens: number; + model: string; +} + +export interface ToolCallResponse { + toolName: string; + toolInput: Record; + inputTokens: number; + outputTokens: number; + rawModel: string; // 实际服务返回的 model 字段 +} + +export interface PingRequest { + model: string; +} + +export interface PingResponse { + ok: true; + inputTokens: number; + outputTokens: number; +} + +/** + * 所有 Provider 实现都满足此接口 + */ +export interface AiProvider { + format: 'anthropic' | 'openai'; + /** 调一次 tool 工具,返回归一化结果 */ + callTool(req: ToolCallRequest): Promise; + /** 极简调用,验证 Key + 端点可用 */ + ping(req: PingRequest): Promise; +} diff --git a/apps/server/src/modules/config/ai-config.controller.ts b/apps/server/src/modules/config/ai-config.controller.ts new file mode 100644 index 0000000..b04aeeb --- /dev/null +++ b/apps/server/src/modules/config/ai-config.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + BadRequestException, +} from '@nestjs/common'; +import { AiConfigService } from './ai-config.service'; +import { + UpsertProviderDto, + TestProviderDto, + ActivateProviderDto, +} from './dto/update-ai-config.dto'; +import { createProvider } from '../ai/providers/factory'; + +@Controller('config/ai') +export class AiConfigController { + constructor(private readonly svc: AiConfigService) {} + + @Get() + async get() { + return this.svc.getPublic(); + } + + /** + * 创建或更新一个提供商 + */ + @Patch('providers') + async upsert(@Body() dto: UpsertProviderDto) { + return this.svc.upsertProvider(dto, dto.operator); + } + + /** + * 删除提供商 + */ + @Delete('providers/:id') + async remove(@Param('id') id: string, @Query('operator') operator?: string) { + return this.svc.deleteProvider(id, operator); + } + + /** + * 切换激活提供商 + */ + @Post('activate') + async activate(@Body() dto: ActivateProviderDto) { + return this.svc.activate(dto.id, dto.operator); + } + + /** + * 测试某个提供商连通性 + */ + @Post('test') + async test(@Body() dto: TestProviderDto) { + if (!dto.id) throw new BadRequestException('id 必填'); + const fullProvider = await this.svc.getProviderFullById(dto.id); + if (!fullProvider) throw new BadRequestException('提供商不存在'); + + try { + const provider = createProvider(fullProvider); + const r = await provider.ping({ model: fullProvider.model }); + return { + ok: true, + message: `连接成功(${fullProvider.model} · 输入 ${r.inputTokens}tok 输出 ${r.outputTokens}tok)`, + }; + } catch (e: any) { + return { + ok: false, + error: e?.message || '调用失败', + }; + } + } +} diff --git a/apps/server/src/modules/config/ai-config.service.ts b/apps/server/src/modules/config/ai-config.service.ts new file mode 100644 index 0000000..b6cd2c7 --- /dev/null +++ b/apps/server/src/modules/config/ai-config.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import type { + AiConfigPublic, + AiConfigStored, + AiProviderConfig, + AiProviderPublic, + AiProviderUpsertInput, +} from '@ftb/shared'; + +const CONFIG_FILE = join(process.cwd(), 'data', 'ai-config.json'); + +const DEFAULT_MODEL = 'claude-sonnet-4-6'; + +@Injectable() +export class AiConfigService { + private readonly logger = new Logger(AiConfigService.name); + private cache: AiConfigStored | null = null; + + /** + * 当前激活的提供商配置(含完整 apiKey),用于 AiGateway 实例化客户端 + */ + async getActiveProvider(): Promise { + const cfg = await this.read(); + if (!cfg.activeProviderId) { + // 兜底:环境变量 + const envKey = process.env.ANTHROPIC_API_KEY; + if (envKey) { + return { + id: '__env__', + name: '环境变量', + format: 'anthropic', + baseURL: 'https://api.anthropic.com', + apiKey: envKey, + model: DEFAULT_MODEL, + createdAt: new Date(0).toISOString(), + }; + } + return null; + } + return cfg.providers.find((p) => p.id === cfg.activeProviderId) ?? null; + } + + /** + * 前端用的整体视图 + */ + async getPublic(): Promise { + const cfg = await this.read(); + return { + providers: cfg.providers.map((p) => this.toPublic(p, p.id === cfg.activeProviderId)), + activeProviderId: cfg.activeProviderId, + updatedAt: cfg.updatedAt, + updatedBy: cfg.updatedBy, + }; + } + + /** + * 创建或更新提供商 + * 若 input.apiKey 为空且 id 已存在,则保留原 key + */ + async upsertProvider(input: AiProviderUpsertInput, operator?: string): Promise { + if (!input.id) throw new BadRequestException('id 必填'); + if (!input.name) throw new BadRequestException('name 必填'); + if (!input.baseURL) throw new BadRequestException('baseURL 必填'); + if (!input.model) throw new BadRequestException('model 必填'); + + const cfg = await this.read(); + const existingIdx = cfg.providers.findIndex((p) => p.id === input.id); + + const apiKey = input.apiKey || cfg.providers[existingIdx]?.apiKey; + if (!apiKey) throw new BadRequestException('apiKey 必填'); + + const provider: AiProviderConfig = { + id: input.id, + name: input.name, + format: input.format, + baseURL: input.baseURL, + apiKey, + model: input.model, + remark: input.remark, + createdAt: + existingIdx >= 0 ? cfg.providers[existingIdx].createdAt : new Date().toISOString(), + }; + + if (existingIdx >= 0) { + cfg.providers[existingIdx] = provider; + } else { + cfg.providers.push(provider); + // 第一个新增的自动激活 + if (!cfg.activeProviderId) cfg.activeProviderId = provider.id; + } + + cfg.updatedAt = new Date().toISOString(); + cfg.updatedBy = operator; + await this.write(cfg); + return this.getPublic(); + } + + async deleteProvider(id: string, operator?: string): Promise { + const cfg = await this.read(); + const idx = cfg.providers.findIndex((p) => p.id === id); + if (idx < 0) throw new NotFoundException('提供商不存在'); + cfg.providers.splice(idx, 1); + if (cfg.activeProviderId === id) { + cfg.activeProviderId = cfg.providers[0]?.id; + } + cfg.updatedAt = new Date().toISOString(); + cfg.updatedBy = operator; + await this.write(cfg); + return this.getPublic(); + } + + async activate(id: string, operator?: string): Promise { + const cfg = await this.read(); + if (!cfg.providers.find((p) => p.id === id)) { + throw new NotFoundException('提供商不存在'); + } + cfg.activeProviderId = id; + cfg.updatedAt = new Date().toISOString(); + cfg.updatedBy = operator; + await this.write(cfg); + return this.getPublic(); + } + + /** + * 取某个提供商的完整配置(含 apiKey),仅供同进程内的服务调用(如 ping/decompose) + */ + async getProviderFullById(id: string): Promise { + const cfg = await this.read(); + return cfg.providers.find((p) => p.id === id) ?? null; + } + + private async read(): Promise { + if (this.cache) return this.cache; + try { + const buf = await fs.readFile(CONFIG_FILE, 'utf-8'); + const parsed = JSON.parse(buf); + this.cache = this.migrate(parsed); + return this.cache!; + } catch (e: any) { + if (e.code !== 'ENOENT') this.logger.warn(`读取 ai-config.json 失败: ${e.message}`); + this.cache = { providers: [] }; + return this.cache; + } + } + + /** + * 旧数据格式(单 apiKey + model)迁移到新格式(providers[]) + */ + private migrate(raw: any): AiConfigStored { + if (Array.isArray(raw?.providers)) return raw as AiConfigStored; + // 旧格式:{ anthropicApiKey, model, updatedAt, updatedBy } + if (raw?.anthropicApiKey) { + this.logger.log('检测到旧版 ai-config.json,自动迁移为多 provider 结构'); + const legacy: AiProviderConfig = { + id: 'anthropic-official', + name: 'Anthropic 官方', + format: 'anthropic', + baseURL: 'https://api.anthropic.com', + apiKey: raw.anthropicApiKey, + model: raw.model || DEFAULT_MODEL, + createdAt: raw.updatedAt || new Date().toISOString(), + }; + return { + providers: [legacy], + activeProviderId: legacy.id, + updatedAt: raw.updatedAt, + updatedBy: raw.updatedBy, + }; + } + return { providers: [] }; + } + + private async write(data: AiConfigStored): Promise { + await fs.mkdir(join(process.cwd(), 'data'), { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf-8'); + this.cache = data; + } + + private toPublic(p: AiProviderConfig, isActive: boolean): AiProviderPublic { + return { + id: p.id, + name: p.name, + format: p.format, + baseURL: p.baseURL, + keyMask: this.mask(p.apiKey), + model: p.model, + remark: p.remark, + createdAt: p.createdAt, + isActive, + }; + } + + private mask(key: string): string { + if (!key) return ''; + if (key.length <= 10) return '****'; + return `${key.slice(0, 6)}...${key.slice(-4)}`; + } +} diff --git a/apps/server/src/modules/config/config.module.ts b/apps/server/src/modules/config/config.module.ts new file mode 100644 index 0000000..e553be2 --- /dev/null +++ b/apps/server/src/modules/config/config.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AiConfigController } from './ai-config.controller'; +import { AiConfigService } from './ai-config.service'; + +@Module({ + controllers: [AiConfigController], + providers: [AiConfigService], + exports: [AiConfigService], +}) +export class ConfigModule {} diff --git a/apps/server/src/modules/config/dto/update-ai-config.dto.ts b/apps/server/src/modules/config/dto/update-ai-config.dto.ts new file mode 100644 index 0000000..4d29e55 --- /dev/null +++ b/apps/server/src/modules/config/dto/update-ai-config.dto.ts @@ -0,0 +1,44 @@ +import { IsOptional, IsString, IsIn } from 'class-validator'; + +export class UpsertProviderDto { + @IsString() + id!: string; + + @IsString() + name!: string; + + @IsIn(['anthropic', 'openai']) + format!: 'anthropic' | 'openai'; + + @IsString() + baseURL!: string; + + @IsOptional() + @IsString() + apiKey?: string; + + @IsString() + model!: string; + + @IsOptional() + @IsString() + remark?: string; + + @IsOptional() + @IsString() + operator?: string; +} + +export class TestProviderDto { + @IsString() + id!: string; +} + +export class ActivateProviderDto { + @IsString() + id!: string; + + @IsOptional() + @IsString() + operator?: string; +} diff --git a/apps/server/src/modules/data/data-keys.ts b/apps/server/src/modules/data/data-keys.ts new file mode 100644 index 0000000..b9d8f8a --- /dev/null +++ b/apps/server/src/modules/data/data-keys.ts @@ -0,0 +1,18 @@ +export const APP_DATA_KEYS = [ + 'products-overview', + 'requirements', + 'version-plans', + 'dev-tasks', + 'test-cases', + 'bugs', + 'members', + 'task-categories', + 'task-worklogs', + 'overtime', +] as const; + +export type AppDataKey = (typeof APP_DATA_KEYS)[number]; + +export function isAppDataKey(key: string): key is AppDataKey { + return (APP_DATA_KEYS as readonly string[]).includes(key); +} diff --git a/apps/server/src/modules/data/data.controller.ts b/apps/server/src/modules/data/data.controller.ts new file mode 100644 index 0000000..102d11f --- /dev/null +++ b/apps/server/src/modules/data/data.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { DataService } from './data.service'; + +@Controller('data') +export class DataController { + constructor(private readonly dataService: DataService) {} + + @Get(':key') + get(@Param('key') key: string) { + return this.dataService.get(key); + } + + @Put(':key') + put(@Param('key') key: string, @Body('value') value: unknown) { + return this.dataService.put(key, value); + } +} diff --git a/apps/server/src/modules/data/data.module.ts b/apps/server/src/modules/data/data.module.ts new file mode 100644 index 0000000..6f73bb4 --- /dev/null +++ b/apps/server/src/modules/data/data.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DataController } from './data.controller'; +import { DataService } from './data.service'; + +@Module({ + controllers: [DataController], + providers: [DataService], +}) +export class DataModule {} diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts new file mode 100644 index 0000000..ca1d56a --- /dev/null +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; +import { DataService } from './data.service'; + +describe('DataService', () => { + const makeService = () => { + const prisma = { + appData: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }; + return { + prisma, + service: new DataService(prisma as any), + }; + }; + + it('returns null for an allowed key with no stored value', async () => { + const { prisma, service } = makeService(); + prisma.appData.findUnique.mockResolvedValue(null); + + await expect(service.get('products-overview')).resolves.toEqual({ + key: 'products-overview', + value: null, + }); + }); + + it('rejects unknown keys', async () => { + const { service } = makeService(); + + await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('upserts JSON values for allowed keys', async () => { + const { prisma, service } = makeService(); + const value = [{ id: 'p1', name: 'Product 1' }]; + prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value }); + + await expect(service.put('products-overview', value)).resolves.toEqual({ + key: 'products-overview', + value, + }); + expect(prisma.appData.upsert).toHaveBeenCalledWith({ + where: { key: 'products-overview' }, + update: { value }, + create: { key: 'products-overview', value }, + }); + }); + + it('allows supporting business data keys migrated from browser storage', async () => { + const { prisma, service } = makeService(); + const value: unknown[] = []; + prisma.appData.upsert.mockImplementation(({ where }) => + Promise.resolve({ key: where.key, value }), + ); + + await expect(service.put('task-worklogs', value)).resolves.toEqual({ + key: 'task-worklogs', + value, + }); + await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({ + key: 'overtime', + value, + }); + }); +}); diff --git a/apps/server/src/modules/data/data.service.ts b/apps/server/src/modules/data/data.service.ts new file mode 100644 index 0000000..4b3f83b --- /dev/null +++ b/apps/server/src/modules/data/data.service.ts @@ -0,0 +1,32 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { isAppDataKey } from './data-keys'; + +@Injectable() +export class DataService { + constructor(private prisma: PrismaService) {} + + async get(key: string) { + this.ensureAllowedKey(key); + const row = await this.prisma.appData.findUnique({ where: { key } }); + return { key, value: row?.value ?? null }; + } + + async put(key: string, value: unknown) { + this.ensureAllowedKey(key); + const jsonValue = value as Prisma.InputJsonValue; + const row = await this.prisma.appData.upsert({ + where: { key }, + update: { value: jsonValue }, + create: { key, value: jsonValue }, + }); + return { key: row.key, value: row.value }; + } + + private ensureAllowedKey(key: string) { + if (!isAppDataKey(key)) { + throw new BadRequestException(`Unsupported data key: ${key}`); + } + } +} diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts index bb6565f..4ee5164 100644 --- a/apps/server/src/prisma/prisma.service.ts +++ b/apps/server/src/prisma/prisma.service.ts @@ -1,13 +1,26 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + private connected = false; + async onModuleInit() { - await this.$connect(); + try { + await this.$connect(); + this.connected = true; + this.logger.log('Prisma connected'); + } catch (e: any) { + this.logger.warn( + `数据库连接失败 (${e?.errorCode || e?.message || e}) — 跳过,需要 DB 的接口将不可用,但不影响 AI 配置 / 拆解模块`, + ); + } } async onModuleDestroy() { - await this.$disconnect(); + if (this.connected) { + await this.$disconnect(); + } } } diff --git a/apps/web/app/admin/ai-config/page.tsx b/apps/web/app/admin/ai-config/page.tsx new file mode 100644 index 0000000..3460405 --- /dev/null +++ b/apps/web/app/admin/ai-config/page.tsx @@ -0,0 +1,503 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Sparkles, Plus, CheckCircle2, AlertTriangle, Loader2, Trash2, Pencil, X } from 'lucide-react'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; +import { api } from '@/lib/api'; +import type { + AiConfigPublic, + AiProviderPublic, + AiProviderUpsertInput, + AiProviderFormat, +} from '@ftb/shared'; + +const FORMAT_LABEL: Record = { + anthropic: 'Anthropic 兼容', + openai: 'OpenAI 兼容', +}; + +const FORMAT_HINT: Record = { + anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站', + openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站', +}; + +const PRESETS: Array<{ key: string; label: string; data: Partial & { format: AiProviderFormat; model: string } }> = [ + { + key: 'anthropic-official', + label: 'Anthropic 官方', + data: { id: 'anthropic-official', name: 'Anthropic 官方', format: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' }, + }, + { + key: 'openai-official', + label: 'OpenAI 官方', + data: { id: 'openai-official', name: 'OpenAI 官方', format: 'openai', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' }, + }, + { + key: 'custom-anthropic', + label: '+ 自定义(Anthropic 兼容)', + data: { id: '', name: '', format: 'anthropic', baseURL: '', model: 'claude-sonnet-4-6' }, + }, + { + key: 'custom-openai', + label: '+ 自定义(OpenAI 兼容)', + data: { id: '', name: '', format: 'openai', baseURL: '', model: 'gpt-4o' }, + }, +]; + +export default function AiConfigPage() { + return ( + + + + ); +} + +function SuperAdminGuard({ children }: { children: React.ReactNode }) { + const user = useAuthStore((s) => s.user); + const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId)); + const isSuperAdmin = !!role && role.permissions.includes('*'); + if (!isSuperAdmin) { + return ( +
+

仅超级管理员可访问 AI 配置

+
+ ); + } + return <>{children}; +} + +function AiConfigContent() { + const user = useAuthStore((s) => s.user); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [testing, setTesting] = useState(null); + const [testResults, setTestResults] = useState>({}); + const [actionMessage, setActionMessage] = useState(null); + + const fetchConfig = async () => { + setLoading(true); + try { + const c = await api.get('/config/ai'); + setConfig(c); + } catch (e: any) { + setActionMessage(`读取配置失败:${e.message}`); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchConfig(); }, []); + + const handleActivate = async (id: string) => { + try { + const next = await api.post('/config/ai/activate', { id, operator: user?.name }); + setConfig(next); + setActionMessage(`已激活:${next.providers.find((p) => p.id === id)?.name}`); + } catch (e: any) { + setActionMessage(`激活失败:${e.message}`); + } + }; + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`确认删除提供商 "${name}"?`)) return; + try { + const next = await api.delete(`/config/ai/providers/${id}?operator=${encodeURIComponent(user?.name || '')}`); + setConfig(next); + setActionMessage(`已删除:${name}`); + } catch (e: any) { + setActionMessage(`删除失败:${e.message}`); + } + }; + + const handleTest = async (id: string) => { + setTesting(id); + setTestResults((prev) => ({ ...prev, [id]: undefined as any })); + try { + const r = await api.post<{ ok: boolean; message?: string; error?: string }>('/config/ai/test', { id }); + setTestResults((prev) => ({ + ...prev, + [id]: { ok: r.ok, message: r.ok ? r.message || '连接成功' : r.error || '连接失败' }, + })); + } catch (e: any) { + setTestResults((prev) => ({ ...prev, [id]: { ok: false, message: e.message } })); + } finally { + setTesting(null); + } + }; + + const activeProvider = config?.providers.find((p) => p.isActive); + + return ( +
+
+ +

AI 配置

+
+ +
+
+ {loading ? ( +
加载中…
+ ) : ( + <> + {/* 当前激活 */} +
+

当前激活

+ {activeProvider ? ( +
+
{activeProvider.name}
+
+ {FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL} +
+
+ {activeProvider.keyMask} · {activeProvider.model} +
+
+ ) : ( +
尚未配置任何提供商,AI 拆解功能不可用
+ )} +
+ + {/* 操作消息 */} + {actionMessage && ( +
+ {actionMessage} + +
+ )} + + {/* 提供商列表 */} +
+
+

已配置的提供商

+ +
+ + {config?.providers.length === 0 ? ( +
+

还没有任何提供商,点上方"新增"添加

+
+ ) : ( +
+ {config?.providers.map((p) => ( + handleActivate(p.id)} + onEdit={() => { setEditingProvider(p); setShowModal(true); }} + onDelete={() => handleDelete(p.id, p.name)} + onTest={() => handleTest(p.id)} + /> + ))} +
+ )} +
+ + )} +
+
+ + {showModal && ( + setShowModal(false)} + onSaved={(next) => { + setConfig(next); + setShowModal(false); + setActionMessage('保存成功'); + }} + operator={user?.name} + /> + )} +
+ ); +} + +function ProviderRow({ + provider, + testing, + testResult, + onActivate, + onEdit, + onDelete, + onTest, +}: { + provider: AiProviderPublic; + testing: boolean; + testResult?: { ok: boolean; message: string }; + onActivate: () => void; + onEdit: () => void; + onDelete: () => void; + onTest: () => void; +}) { + return ( +
+
+
+
+ {provider.name} + {provider.isActive && ( + 已激活 + )} + + {FORMAT_LABEL[provider.format]} + +
+
{provider.baseURL}
+
+ {provider.keyMask} · {provider.model} +
+ {provider.remark &&
{provider.remark}
} + {testResult && ( +
+ {testResult.ok ? : } + {testResult.message} +
+ )} +
+
+ {!provider.isActive && ( + + )} + + + +
+
+
+ ); +} + +function ProviderModal({ + provider, + onClose, + onSaved, + operator, +}: { + provider: AiProviderPublic | null; + onClose: () => void; + onSaved: (next: AiConfigPublic) => void; + operator?: string; +}) { + const isEdit = !!provider; + const [id, setId] = useState(provider?.id || ''); + const [name, setName] = useState(provider?.name || ''); + const [format, setFormat] = useState(provider?.format || 'anthropic'); + const [baseURL, setBaseURL] = useState(provider?.baseURL || ''); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(provider?.model || 'claude-sonnet-4-6'); + const [remark, setRemark] = useState(provider?.remark || ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const applyPreset = (presetKey: string) => { + const p = PRESETS.find((x) => x.key === presetKey); + if (!p) return; + setId(p.data.id || ''); + setName(p.data.name || ''); + setFormat(p.data.format); + setBaseURL(p.data.baseURL || ''); + setModel(p.data.model); + }; + + const canSubmit = id.trim() && name.trim() && baseURL.trim() && model.trim() && (isEdit || apiKey.trim()); + + const handleSave = async () => { + if (!canSubmit) return; + setSaving(true); + setError(null); + try { + const payload: AiProviderUpsertInput & { operator?: string } = { + id: id.trim(), + name: name.trim(), + format, + baseURL: baseURL.trim(), + model: model.trim(), + remark: remark.trim() || undefined, + operator, + }; + if (apiKey.trim()) payload.apiKey = apiKey.trim(); + const next = await api.patch('/config/ai/providers', payload); + onSaved(next); + } catch (e: any) { + setError(e?.message || '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

{isEdit ? '编辑提供商' : '新增提供商'}

+ +
+ + {!isEdit && ( +
+ +
+ {PRESETS.map((p) => ( + + ))} +
+
+ )} + +
+
+
+ + setId(e.target.value)} + disabled={isEdit} + placeholder="ikuncode" + 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 disabled:opacity-60" + /> +
+
+ + setName(e.target.value)} + placeholder="ikuncode" + 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" + /> +
+
+ +
+ +
+ {(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => ( + + ))} +
+
+ +
+ + setBaseURL(e.target.value)} + placeholder="https://api.ikuncode.com" + className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={isEdit ? provider!.keyMask : 'sk-...'} + className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" + /> +

出于安全考虑,Key 输入后无法再被读出,仅可重新填写覆盖

+
+ +
+ + setModel(e.target.value)} + placeholder={format === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o'} + className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" + /> +

+ 中转站不会替你选模型 — 必须填写一个该 baseURL 支持的模型 ID(如 claude-sonnet-4-6、claude-opus-4-7、gpt-4o、deepseek-v3 等)。请查阅中转站文档。 +

+
+ +
+ + setRemark(e.target.value)} + placeholder="公司账号 / 个人测试 / ..." + 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" + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index b73480f..14d81b4 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/stores/useAuthStore'; -import { LayoutGrid, Phone, Lock, Eye, EyeOff } from 'lucide-react'; +import { Eye, EyeOff, LayoutGrid, Lock, Phone } from 'lucide-react'; export default function LoginPage() { const router = useRouter(); @@ -15,7 +15,7 @@ export default function LoginPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -25,21 +25,21 @@ export default function LoginPage() { } setLoading(true); - setTimeout(() => { - const success = login(phone.trim(), password, remember); + try { + const success = await login(phone.trim(), password, remember); if (success) { router.push('/products'); } else { setError('手机号或密码错误'); } + } finally { setLoading(false); - }, 300); + } }; return (
- {/* Logo */}
@@ -48,59 +48,57 @@ export default function LoginPage() {

登录以继续使用系统

- {/* Form */}
- +
- + setPhone(e.target.value)} placeholder="请输入手机号" maxLength={11} - className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all" + className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
- +
- + setPassword(e.target.value)} placeholder="请输入密码" - className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all" + className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
-
- -
+ {error && ( -
+
{error}
)} @@ -108,9 +106,9 @@ export default function LoginPage() { diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index 859f1d2..51962a0 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -10,6 +10,8 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; import { getProjectDetail, VersionWithContext } from '@/lib/derive'; import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; @@ -351,9 +353,25 @@ export default function ProjectDetailPage() { const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]); + const user = useAuthStore((s) => s.user); + const currentUserName = user?.name || ''; + const { roles } = useMemberStore(); + const isSuperAdmin = useMemo(() => { + const r = roles.find((x) => x.id === user?.roleId); + return !!r && r.permissions.includes('*'); + }, [roles, user?.roleId]); + const sortedVersions = useMemo(() => { if (!project) return []; let list = [...project.versions]; + // 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部) + if (!isSuperAdmin) { + list = list.filter((v) => { + const ms = v.members ?? []; + if (ms.length === 0) return true; + return ms.some((m) => m.name === currentUserName); + }); + } if (statusFilter !== 'all') { if (statusFilter === 'planned') { list = list.filter((v) => v.status === 'planned'); @@ -364,7 +382,7 @@ export default function ProjectDetailPage() { } } return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - }, [project, statusFilter]); + }, [project, statusFilter, isSuperAdmin, currentUserName]); // Compute actual overall progress per version const versionProgressMap = useMemo(() => { diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 2d2a6e3..0647c99 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -33,6 +33,7 @@ import { testCaseIntervals } from '@/lib/test-case'; import { bugIntervals } from '@/lib/bug'; import { planIntervals } from '@/lib/version-plan'; import { formatDateTime } from '@/lib/format'; +import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; const PRIORITY_STYLE: Record = { P0: 'bg-red-500/10 text-red-600', @@ -145,9 +146,10 @@ export default function VersionDetailPage() { ); } - // 权限校验:只有参与人员可以访问 + // 权限校验:只有参与人员可以访问(超管不受限) const currentUserName = user?.name || ''; - const isMember = (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName); + const isSuperAdmin = !!currentRole && currentRole.permissions.includes('*'); + const isMember = isSuperAdmin || (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName); if (!isMember) { return (
@@ -512,7 +514,7 @@ export default function VersionDetailPage() { {g.items.map((p) => ( {p.resultType === 'file' ? : } - {p.resultFileName || p.title} + {p.resultTitle || p.resultFileName || p.title} ))}
@@ -887,17 +889,18 @@ export default function VersionDetailPage() { ) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? ( (() => { const pt = activeTab as 'research' | 'product' | 'ui'; - const versionReqs = requirements.filter((r) => r.versionId === version.id); - const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner })); + const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId); return ( { createPlan(data); if ((pt === 'product') && data.linkedRequirementIds?.length) { diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 88432c7..2988c22 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -10,6 +10,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; import { flattenVersions, flattenProjects } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status'; @@ -123,13 +124,19 @@ function VersionsPageContent() { const user = useAuthStore((s) => s.user); const currentUserName = user?.name || ''; + const { roles } = useMemberStore(); + const isSuperAdmin = useMemo(() => { + const r = roles.find((x) => x.id === user?.roleId); + return !!r && r.permissions.includes('*'); + }, [roles, user?.roleId]); const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]); - // 只显示当前用户参与的版本(members为空时所有人可见) + // 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部) const allVersions = useMemo(() => allVersionsRaw.filter((v) => { + if (isSuperAdmin) return true; if (!v.members || v.members.length === 0) return true; return v.members.some((m) => m.name === currentUserName); - }), [allVersionsRaw, currentUserName]); + }), [allVersionsRaw, currentUserName, isSuperAdmin]); const allProjects = useMemo(() => flattenProjects(overview), [overview]); // Compute overall progress per version from actual data diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index bcddf92..e8e79ce 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -51,12 +51,17 @@ export function DevTaskRow({ task, category, onClick }: Props) { return (
{task.taskNo}
{task.title} + {task.aiDraft && ( + + AI 草案 + + )} {task.isBlocked && ( 阻塞 diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 841f4b0..9568337 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePathname, useRouter } from 'next/navigation'; -import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; @@ -23,6 +23,7 @@ const NAV_GROUPS = [ items: [ { label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' }, { label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' }, + { label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' }, ], }, ]; diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx new file mode 100644 index 0000000..5973ad4 --- /dev/null +++ b/apps/web/components/version/AiDecomposeButton.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react'; +import type { VersionPlan } from '@/lib/version-plan'; +import type { VersionWithContext } from '@/lib/derive'; +import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; +import { useRequirementStore } from '@/stores/useRequirementStore'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { api } from '@/lib/api'; +import { DecomposeReportModal } from './DecomposeReportModal'; +import type { + AgentDecomposeRequest, + AgentDecomposeResponse, + AgentDecomposeError, +} from '@ftb/shared'; + +interface Props { + plan: VersionPlan; + version: VersionWithContext; +} + +/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */ +const STUCK_THRESHOLD_SEC = 240; + +function formatElapsed(sec: number): string { + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}m${s}s`; +} + +export function AiDecomposeButton({ plan, version }: Props) { + const { updatePlan } = useVersionPlanStore(); + const { requirements } = useRequirementStore(); + const user = useAuthStore((s) => s.user); + + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [tick, setTick] = useState(0); + const startedAtRef = useRef(null); + + // plan 上的状态(持久化在 localStorage) + const persistStatus = plan.aiDecomposeStatus; + const persistError = plan.aiDecomposeError; + const persistAt = plan.aiDecomposeAt; + + // 计算"已用时" + const elapsedSec = (() => { + if (loading && startedAtRef.current) { + return Math.floor((tick - startedAtRef.current) / 1000); + } + if (persistStatus === 'in_progress' && persistAt) { + const start = new Date(persistAt).getTime(); + return Math.max(0, Math.floor((Date.now() - start) / 1000)); + } + return 0; + })(); + + const isStaleInProgress = persistStatus === 'in_progress' && elapsedSec > STUCK_THRESHOLD_SEC; + const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress; + const isError = persistStatus === 'error' && !loading; + const wasCompleted = persistStatus === 'completed' && !loading; + + // tick 每秒更新一次,让"已用时"实时跳 + useEffect(() => { + if (!isInProgress) return; + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, [isInProgress]); + + const linkedReqs = requirements + .filter((r) => r.versionId === version.id) + .map((r) => ({ + id: r.id, + code: r.code, + title: r.title, + description: r.description, + })); + + const handleClick = async () => { + if (loading) return; + // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 + if (persistStatus === 'in_progress' && !isStaleInProgress) return; + + startedAtRef.current = Date.now(); + setTick(Date.now()); + setLoading(true); + + updatePlan(plan.id, { + aiDecomposeStatus: 'in_progress', + aiDecomposeBy: user?.name, + aiDecomposeAt: new Date().toISOString(), + aiDecomposeError: undefined, + }); + + const members = (version.members ?? []).map((m) => ({ + name: m.name, + role: m.role, + })); + + const payload: AgentDecomposeRequest = { + prototypeUrl: plan.resultUrl || '', + requirements: linkedReqs, + members, + versionId: version.id, + planId: plan.id, + }; + + try { + const resp = await api.postRaw( + '/ai/decompose', + payload, + 300000, + ); + if (!resp.ok) { + updatePlan(plan.id, { + aiDecomposeStatus: 'error', + aiDecomposeError: resp.error, + }); + } else { + setResult(resp); + updatePlan(plan.id, { + aiDecomposeStatus: 'completed', + aiDecomposeError: undefined, + }); + } + } catch (e: any) { + const msg = e?.message || '调用 AI 服务失败'; + updatePlan(plan.id, { + aiDecomposeStatus: 'error', + aiDecomposeError: msg, + }); + } finally { + setLoading(false); + startedAtRef.current = null; + } + }; + + if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) { + return null; + } + + return ( + <> + + + {/* 错误信息:在按钮旁悬浮显示 */} + {isError && persistError && ( + + + {persistError} + + )} + + {/* 卡死提示:persistStatus 是 in_progress 但已超时 */} + {isStaleInProgress && ( + + 上次未完成({formatElapsed(elapsedSec)}) + + )} + + {result && ( + setResult(null)} + /> + )} + + ); +} diff --git a/apps/web/components/version/DecomposeReportModal.tsx b/apps/web/components/version/DecomposeReportModal.tsx new file mode 100644 index 0000000..c6a3b16 --- /dev/null +++ b/apps/web/components/version/DecomposeReportModal.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { X, CheckCircle2, AlertTriangle, HelpCircle, ListChecks } from 'lucide-react'; +import type { VersionPlan } from '@/lib/version-plan'; +import type { VersionWithContext } from '@/lib/derive'; +import { useDevTaskStore } from '@/stores/useDevTaskStore'; +import { useTestCaseStore } from '@/stores/useTestCaseStore'; +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category'; +import { addWorkHours } from '@/lib/work-hours'; +import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; +import type { + AgentDecomposeResponse, + AgentDevTaskDraft, + AgentTestCaseDraft, +} from '@ftb/shared'; + +interface Props { + result: AgentDecomposeResponse; + version: VersionWithContext; + plan: VersionPlan; + requirements: Array<{ id: string; code: string; title: string; description?: string }>; + onClose: () => void; +} + +export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) { + const { createTask } = useDevTaskStore(); + const { createTestCase } = useTestCaseStore(); + const { categories } = useTaskCategoryStore(); + const user = useAuthStore((s) => s.user); + + const { result: data, meta } = result; + + const [selectedDevIdx, setSelectedDevIdx] = useState>( + new Set(data.devTaskDrafts.map((_, i) => i)), + ); + const [selectedTcIdx, setSelectedTcIdx] = useState>( + new Set(data.testCaseDrafts.map((_, i) => i)), + ); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + const reqByCode = useMemo(() => { + const m = new Map(); + for (const r of requirements) m.set(r.code, r); + return m; + }, [requirements]); + + const reqByInternalId = useMemo(() => { + const m = new Map(); + for (const r of requirements) m.set(r.id, r); + return m; + }, [requirements]); + + const toggleDev = (i: number) => { + const n = new Set(selectedDevIdx); + if (n.has(i)) n.delete(i); + else n.add(i); + setSelectedDevIdx(n); + }; + const toggleTc = (i: number) => { + const n = new Set(selectedTcIdx); + if (n.has(i)) n.delete(i); + else n.add(i); + setSelectedTcIdx(n); + }; + + const handleAdopt = () => { + if (submitting) return; + setSubmitting(true); + const now = new Date().toISOString(); + + // 把每个 reference 的 requirement 类型 id(AI 给的是 code)映射回内部 id + const normalizeRefs = (refs: AgentDevTaskDraft['references'] | AgentTestCaseDraft['references']) => + refs.map((ref) => { + if (ref.type === 'requirement') { + const r = reqByCode.get(ref.id) ?? reqByInternalId.get(ref.id); + if (r) return { ...ref, id: r.id, label: ref.label || `${r.code} ${r.title}` }; + } + return ref; + }); + + let devCount = 0; + for (let i = 0; i < data.devTaskDrafts.length; i++) { + if (!selectedDevIdx.has(i)) continue; + const draft = data.devTaskDrafts[i]; + const refs = normalizeRefs(draft.references); + const reqRef = refs.find((r) => r.type === 'requirement'); + const requirementId = reqRef?.id ?? requirements[0]?.id ?? ''; + const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); + const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours); + const startISO = new Date().toISOString(); + const endISO = addWorkHours(startISO, estimateHours); + + createTask({ + requirementId, + title: draft.title, + description: draft.description, + categoryId, + assigneeId: '', + reviewerId: undefined, + priority: draft.priority, + expectedStartAt: startISO, + expectedEndAt: endISO, + estimateHours, + actualStartAt: undefined, + actualEndAt: undefined, + status: 'todo', + blockReason: undefined, + blockedById: undefined, + predecessorIds: undefined, + riskLevel: undefined, + delayReason: undefined, + overdueVersionReason: undefined, + references: refs, + aiDraft: true, + aiDraftAt: now, + createdBy: user?.name || 'AI', + } as any); + devCount++; + } + + let tcCount = 0; + for (let i = 0; i < data.testCaseDrafts.length; i++) { + if (!selectedTcIdx.has(i)) continue; + const draft = data.testCaseDrafts[i]; + const refs = normalizeRefs(draft.references); + const reqRef = refs.find((r) => r.type === 'requirement'); + const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); + const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours); + + createTestCase({ + versionId: version.id, + requirementId: reqRef?.id, + title: draft.title, + description: draft.description, + categoryId, + priority: draft.priority, + estimateHours, + assigneeId: undefined, + references: refs, + aiDraft: true, + aiDraftAt: now, + createdBy: user?.name || 'AI', + } as any); + tcCount++; + } + + setSubmitted(true); + setSubmitting(false); + + // 1.5 秒后自动关闭 + setTimeout(() => onClose(), 1500); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

AI 拆解结果

+ + {meta.model} · 输入 {meta.inputTokens} tok · 输出 {meta.outputTokens} tok ·{' '} + {(meta.durationMs / 1000).toFixed(1)}s + +
+ +
+ + {/* Body */} +
+ {/* 对账报告 */} +
+

对账报告

+ + {data.report.matched.length > 0 && ( +
+
+ + + 完美对应({data.report.matched.length}) + +
+
    + {data.report.matched.map((m, i) => ( +
  • + {m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务 +
  • + ))} +
+
+ )} + + {data.report.reqOnly.length > 0 && ( +
+
+ + 仅需求未见原型 +
+

+ 以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对: +

+
    + {data.report.reqOnly.map((id, i) =>
  • {id}
  • )} +
+
+ )} + + {data.report.noteOnly.length > 0 && ( +
+
+ + 仅原型未见需求 +
+

+ 以下原型批注在需求清单中未提及,已跳过拆解(可能是漏录需求): +

+
    + {data.report.noteOnly.map((id, i) =>
  • {id}
  • )} +
+
+ )} + + {data.report.ambiguous.length > 0 && ( +
+
+ + 含糊批注 +
+
    + {data.report.ambiguous.map((a, i) => ( +
  • + {a.noteId}:{a.reason} +
  • + ))} +
+
+ )} +
+ + {/* DevTask 草案 */} +
+

+ 开发任务草案({data.devTaskDrafts.length}) +

+ {data.devTaskDrafts.length === 0 ? ( +

无可生成的任务

+ ) : ( +
+ {data.devTaskDrafts.map((d, i) => ( + + ))} +
+ )} +
+ + {/* TestCase 草案 */} +
+

+ 测试用例草案({data.testCaseDrafts.length}) +

+ {data.testCaseDrafts.length === 0 ? ( +

无可生成的用例

+ ) : ( +
+ {data.testCaseDrafts.map((d, i) => ( + + ))} +
+ )} +
+
+ + {/* Footer */} +
+
+ 已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例 +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/components/version/PlanDetailDrawer.tsx b/apps/web/components/version/PlanDetailDrawer.tsx index 5fca3ed..fc84f2b 100644 --- a/apps/web/components/version/PlanDetailDrawer.tsx +++ b/apps/web/components/version/PlanDetailDrawer.tsx @@ -8,6 +8,7 @@ import { useMemberStore } from '@/stores/useMemberStore'; import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { formatDateTime } from '@/lib/format'; import type { PlanTask, VersionPlan } from '@/lib/version-plan'; +import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow'; interface Props { planId: string; @@ -26,6 +27,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); const [resultType, setResultType] = useState<'link' | 'file'>('link'); + const [resultTitle, setResultTitle] = useState(''); const [resultUrl, setResultUrl] = useState(''); const [fileName, setFileName] = useState(''); const [fileData, setFileData] = useState(''); @@ -34,20 +36,22 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { const plan = plans.find((p) => p.id === planId); if (!plan) return null; + const completionState = getPlanCompletionState(plan); const isResearch = plan.type === 'research'; const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds); const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[]; - const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date()); + const canToggle = canTogglePlanChecklist(plan); + const canEditCoverage = canEditPlanRequirementCoverage(plan); const handleToggleTask = (task: PlanTask) => { - if (!canInteract) return; + if (!canToggle) return; const nextStatus = task.status === 'completed' ? 'pending' : 'completed'; const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t); updatePlan(plan.id, { tasks: updatedTasks }); }; const handleToggleReq = (reqId: string) => { - if (!canInteract) return; + if (!canEditCoverage) return; const current = plan.completedRequirementIds || []; const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId]; updatePlan(plan.id, { completedRequirementIds: next }); @@ -64,8 +68,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { const handleSubmitResult = () => { const url = resultType === 'link' ? resultUrl.trim() : fileData; - if (!url) return; - completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined }); + const title = resultTitle.trim(); + if (!url || !title) return; + const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined }); + if (response && typeof response === 'object' && 'ok' in response && !response.ok) { + alert(response.message || '计划未满足完成条件'); + return; + } setShowComplete(false); }; @@ -138,9 +147,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { {plan.tasks.map((task) => (
@@ -151,7 +160,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { )} {/* Product/UI: Linked Requirements */} - {!isResearch && linkedReqs.length > 0 && ( + {linkedReqs.length > 0 && (
关联需求
{linkedReqs.map((req) => { @@ -159,9 +168,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { return (
@@ -170,6 +179,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
); })} + {plan.status === 'in_progress' && !completionState.canSubmitResult && ( +

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

+ )}
)} @@ -180,7 +192,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
@@ -212,6 +224,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { {showComplete && (
提交成果
+ setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
@@ -225,7 +238,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)}
- +
@@ -234,21 +247,26 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { {/* Footer Actions */} {plan.status !== 'completed' && ( -
- {plan.status === 'pending' && ( - + )} + {plan.status === 'in_progress' && ( + + )} + +
+ {plan.status === 'in_progress' && !completionState.canSubmitResult && ( +
还不能提交成果:{completionState.missingReasons.join('、')}
)} - {plan.status === 'in_progress' && ( - - )} - -
+ )}
diff --git a/apps/web/components/version/PlanTab.tsx b/apps/web/components/version/PlanTab.tsx index 3fb5a98..bdd5a8c 100644 --- a/apps/web/components/version/PlanTab.tsx +++ b/apps/web/components/version/PlanTab.tsx @@ -1,23 +1,30 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react'; import type { VersionPlan, PlanTask } from '@/lib/version-plan'; import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { formatDateTime } from '@/lib/format'; import { FieldError } from '@/components/FieldError'; +import { AiDecomposeButton } from './AiDecomposeButton'; +import type { VersionWithContext } from '@/lib/derive'; +import type { Requirement } from '@/lib/requirement'; +import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector'; +import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow'; interface Props { plans: VersionPlan[]; versionId: string; + version?: VersionWithContext; versionDeadline?: string; currentUserName: string; planType: 'research' | 'product' | 'ui'; versionMembers: { role: string; name: string }[]; - linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[]; + linkedRequirements?: Requirement[]; + allRequirements?: Requirement[]; onCreate: (data: Omit) => void; onUpdate: (id: string, data: Partial) => void; - onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void; + onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void; onDelete: (id: string) => void; } @@ -29,7 +36,7 @@ const STATUS_STYLE = { }; const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' }; -export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) { +export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) { const [showCreateModal, setShowCreateModal] = useState(false); const [editingPlan, setEditingPlan] = useState(null); const [completingPlan, setCompletingPlan] = useState(null); @@ -65,6 +72,10 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl const autoStarted = plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date(); const effectiveStatus = autoStarted ? 'in_progress' : plan.status; const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null); + const completionState = getPlanCompletionState(plan); + const canToggle = canTogglePlanChecklist(plan); + const canEditCoverage = canEditPlanRequirementCoverage(plan); + const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []); // 耗时用实际时间戳计算 const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt ? calcPlanDuration(plan.actualStartAt, plan.completedAt) @@ -102,11 +113,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{plan.resultType === 'link' ? : } - {plan.resultFileName || '查看成果'} + {plan.resultTitle || plan.resultFileName || '查看成果'} + {planType === 'product' && version && ( + + )}
)} - {/* 调研:任务进度 */} + {/* 子任务 */} {plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
@@ -119,14 +133,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl {plan.tasks.map((task) => (
@@ -139,8 +153,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
)} - {/* 产品方案/UI:关联需求进度 */} - {(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && ( + {/* 关联需求 */} + {plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
@@ -150,19 +164,19 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{plan.linkedRequirementIds.map((rid) => { - const req = linkedRequirements.find((r) => r.id === rid); + const req = requirementOptions.find((r) => r.id === rid); const isDone = (plan.completedRequirementIds || []).includes(rid); return req ? (
@@ -172,14 +186,11 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl ) : null; })}
- {calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && plan.status !== 'completed' && ( -
- 所有需求已完成,请提交原型成果 - -
- )}
)} + {plan.status === 'in_progress' && !completionState.canSubmitResult && ( +

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

+ )}
{plan.status === 'pending' && !autoStarted && ( @@ -187,11 +198,6 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl 开始 )} - {plan.status !== 'completed' && plan.status !== 'pending' && ( - - )} {plan.status !== 'completed' && ( <>
)} + {plan.status !== 'completed' && completionState.canSubmitResult && ( +
+ 已满足提交成果条件 + +
+ )}
); })} @@ -232,6 +244,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl versionDeadline={versionDeadline} currentUserName={currentUserName} linkedRequirements={linkedRequirements} + allRequirements={allRequirements} onClose={() => { setShowCreateModal(false); setEditingPlan(null); }} onSubmit={(data) => { if (editingPlan) onUpdate(editingPlan.id, data); @@ -245,20 +258,28 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl {completingPlan && ( setCompletingPlan(null)} - onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }} + onSubmit={(result) => { + const response = onComplete(completingPlan.id, result); + if (response && typeof response === 'object' && 'ok' in response && !response.ok) { + alert(response.message || '计划未满足完成条件'); + return; + } + setCompletingPlan(null); + }} /> )}
); } -function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: { +function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: { initial: VersionPlan | null; planType: 'research' | 'product' | 'ui'; versionId: string; versionDeadline?: string; currentUserName: string; - linkedRequirements?: { id: string; title: string; code: string }[]; + linkedRequirements?: Requirement[]; + allRequirements?: Requirement[]; onClose: () => void; onSubmit: (data: any) => void; }) { @@ -273,7 +294,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? ''); const [selectedReqs, setSelectedReqs] = useState>(new Set(initial?.linkedRequirementIds ?? [])); const [endTimeError, setEndTimeError] = useState(''); - const showReqSelect = planType === 'product' || planType === 'ui'; + const requirementOptions = useMemo( + () => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)), + [linkedRequirements, allRequirements, selectedReqs], + ); const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline)); const handleSubmit = (e: React.FormEvent) => { @@ -294,8 +318,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU startTime, endTime, status: initial?.status ?? 'pending', - linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined, - tasks: tasks.length > 0 ? tasks : undefined, + linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined, + tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined, remark: remark.trim() || undefined, overdueReason: isOverdue ? overdueReason.trim() : undefined, addedBy: currentUserName, @@ -345,63 +369,83 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU />
)} - {showReqSelect && linkedRequirements && linkedRequirements.length > 0 && ( + {requirementOptions.length > 0 && (
+
+ 已选 {selectedReqs.size} / {requirementOptions.filter((req) => !req.isHistorical).length} +
+ + +
+
- {linkedRequirements.map((req) => ( + {requirementOptions.map((req) => ( ))}
)} {planType === 'research' && ( -
- - {/* 预设选项 */} - {tasks.length === 0 && ( -
- {['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => ( - +
+ + {/* 预设选项 */} + {tasks.length === 0 && ( +
+ {['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => ( + + ))} +
+ )} +
+ {tasks.map((task, i) => ( +
+ {task.title} + +
))}
- )} -
- {tasks.map((task, i) => ( -
- {task.title} - -
- ))} +
+ setNewTaskTitle(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }} + placeholder="自定义调研方向,回车添加" + className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" + /> + +
+ {tasks.length === 0 && ( +
请至少添加一项调研方向
+ )}
-
- setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }} - placeholder="自定义任务名称,回车添加" - className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" - /> - -
- {tasks.length === 0 && ( -
请至少添加一项任务
- )} -
)}
@@ -419,9 +463,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU function CompleteModal({ onClose, onSubmit }: { onClose: () => void; - onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void; + onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void; }) { const [resultType, setResultType] = useState<'link' | 'file'>('link'); + const [resultTitle, setResultTitle] = useState(''); const [url, setUrl] = useState(''); const [fileName, setFileName] = useState(''); const [fileData, setFileData] = useState(''); @@ -435,7 +480,7 @@ function CompleteModal({ onClose, onSubmit }: { reader.readAsDataURL(file); }; - const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0; + const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0); return (
@@ -445,6 +490,10 @@ function CompleteModal({ onClose, onSubmit }: {
+
+ + setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
@@ -459,7 +508,7 @@ function CompleteModal({ onClose, onSubmit }: { )}
- +
diff --git a/apps/web/components/version/VersionRequirementsTab.tsx b/apps/web/components/version/VersionRequirementsTab.tsx index 7eaf36e..022c9c2 100644 --- a/apps/web/components/version/VersionRequirementsTab.tsx +++ b/apps/web/components/version/VersionRequirementsTab.tsx @@ -6,6 +6,7 @@ import type { Requirement, ChangeReason } from '@/lib/requirement'; import type { DevTask } from '@/lib/dev-task'; import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement'; import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine'; +import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; interface Props { versionId: string; @@ -23,7 +24,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev const [showAddModal, setShowAddModal] = useState(false); const [showChangeModal, setShowChangeModal] = useState(false); const linkedReqs = requirements.filter((r) => r.versionId === versionId); - const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted'); + const availableReqs = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId); return (
@@ -43,7 +44,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev {linkedReqs.length === 0 ? (
- 暂无关联需求,从需求池中添加 + 暂无关联需求,从当前项目已采纳需求中添加
) : (
@@ -213,7 +214,7 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
e.stopPropagation()}>
-

从需求池添加

+

从项目已采纳需求添加

@@ -221,6 +222,15 @@ function AddRequirementModal({ available, onClose, onConfirm }: { setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
+ {filtered.length > 0 && ( +
+ 当前筛选 {filtered.length} 条,已选 {selected.size} 条 +
+ + +
+
+ )}
{filtered.length === 0 ? ( diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 8478a7d..8235f93 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -8,7 +8,8 @@ async function checkApi(): Promise { if (probePromise) return probePromise; probePromise = (async () => { try { - const res = await fetch(`${API_BASE}/products`, { method: 'HEAD', signal: AbortSignal.timeout(300) }); + // 探测一个不依赖数据库的端点(/config/ai 总是返回 200,只要 NestJS 起来了) + const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) }); apiAvailable = res.ok; } catch { apiAvailable = false; @@ -37,7 +38,29 @@ export const api = { get: (path: string) => request(path), post: (path: string, data: unknown) => request(path, { method: 'POST', body: JSON.stringify(data) }), + put: (path: string, data: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(data) }), patch: (path: string, data: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(data) }), delete: (path: string) => request(path, { method: 'DELETE' }), + postRaw: async (path: string, data: unknown, timeoutMs = 120000): Promise => { + // 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求) + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal: controller.signal, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || `请求失败: ${res.status}`); + } + return res.json(); + } finally { + clearTimeout(tid); + } + }, }; diff --git a/apps/web/lib/product-overview-persistence.test.ts b/apps/web/lib/product-overview-persistence.test.ts new file mode 100644 index 0000000..7673df6 --- /dev/null +++ b/apps/web/lib/product-overview-persistence.test.ts @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { shouldPersistRemoteOverview } from './product-overview-persistence'; + +test('does not persist an empty remote overview', () => { + assert.equal(shouldPersistRemoteOverview([]), false); +}); + +test('persists a non-empty remote overview', () => { + assert.equal(shouldPersistRemoteOverview([{ id: 'product-1' }]), true); +}); diff --git a/apps/web/lib/product-overview-persistence.ts b/apps/web/lib/product-overview-persistence.ts new file mode 100644 index 0000000..ad7e95c --- /dev/null +++ b/apps/web/lib/product-overview-persistence.ts @@ -0,0 +1,3 @@ +export function shouldPersistRemoteOverview(overview: unknown): boolean { + return Array.isArray(overview) && overview.length > 0; +} diff --git a/apps/web/lib/requirement-selector.test.ts b/apps/web/lib/requirement-selector.test.ts new file mode 100644 index 0000000..52f6b64 --- /dev/null +++ b/apps/web/lib/requirement-selector.test.ts @@ -0,0 +1,49 @@ +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: [] as string[], + typeId: '', + priority: 'P2', + effort: 'M', + creator: 'tester', + createdAt: '2026-06-25', +}; + +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: Requirement) => r.id), ['r1']); +}); + +test('keeps historical selected requirements as non-candidate options', () => { + const options = mergeSelectedRequirementOptions( + [req('r1', 'project-1', 'adopted')], + [req('r1', 'project-1', 'adopted'), req('r2', 'project-1', 'developing')], + ['r1', 'r2'], + ); + + assert.equal(options.find((o: { id: string; isHistorical?: boolean }) => o.id === 'r2')?.isHistorical, true); +}); diff --git a/apps/web/lib/requirement-selector.ts b/apps/web/lib/requirement-selector.ts new file mode 100644 index 0000000..cc90743 --- /dev/null +++ b/apps/web/lib/requirement-selector.ts @@ -0,0 +1,42 @@ +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]; +} diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts new file mode 100644 index 0000000..41d1730 --- /dev/null +++ b/apps/web/lib/server-data.ts @@ -0,0 +1,27 @@ +import { api } from './api'; + +export type ServerDataKey = + | 'products-overview' + | 'requirements' + | 'version-plans' + | 'dev-tasks' + | 'test-cases' + | 'bugs' + | 'members' + | 'task-categories' + | 'task-worklogs' + | 'overtime'; + +interface ServerDataResponse { + key: ServerDataKey; + value: T | null; +} + +export async function loadServerData(key: ServerDataKey): Promise { + const res = await api.get>(`/data/${key}`); + return res.value; +} + +export async function saveServerData(key: ServerDataKey, value: T): Promise { + await api.put(`/data/${key}`, { value }); +} diff --git a/apps/web/lib/task-category.test.ts b/apps/web/lib/task-category.test.ts new file mode 100644 index 0000000..4680b2b --- /dev/null +++ b/apps/web/lib/task-category.test.ts @@ -0,0 +1,38 @@ +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 }, + ]); + + 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, '后端接口'); +}); diff --git a/apps/web/lib/task-category.ts b/apps/web/lib/task-category.ts index db07a62..7656acf 100644 --- a/apps/web/lib/task-category.ts +++ b/apps/web/lib/task-category.ts @@ -1,7 +1,10 @@ -export type CategoryGroup = 'development' | 'implementation' | 'other'; +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; @@ -11,19 +14,68 @@ export interface TaskCategory { export const CATEGORY_GROUP_LABEL: Record = { development: '开发', + testing: '测试', implementation: '实施', other: '其他', }; export const PRESET_CATEGORIES: TaskCategory[] = [ - { id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true }, - { id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true }, - { id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true }, - { id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true }, - { id: 'cat-5', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 5, isSystem: true }, - { id: 'cat-6', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 6, isSystem: true }, + { id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true }, + { id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true }, + { id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true }, + { id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', 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 }, ]; +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, index: number): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_') + .replace(/^_+|_+$/g, '') || `category_${index + 1}` + ); +} + +export function normalizeTaskCategory(category: Partial | undefined, index = 0): TaskCategory { + const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id); + const name = category?.name || preset?.name || `分类 ${index + 1}`; + return { + id: category?.id || preset?.id || `cat-${index + 1}`, + code: category?.code || LEGACY_CODE_BY_ID[category?.id ?? ''] || preset?.code || slugifyCategoryName(name, index), + name, + group: category?.group || preset?.group || 'other', + color: category?.color ?? preset?.color, + sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : preset?.sortOrder ?? index + 1, + isSystem: typeof category?.isSystem === 'boolean' ? category.isSystem : Boolean(preset?.isSystem), + }; +} + +export function normalizeTaskCategories(categories: Partial[] = []): TaskCategory[] { + const merged: Partial[] = Array.isArray(categories) ? [...categories] : []; + for (const preset of PRESET_CATEGORIES) { + if (!merged.some((c) => c.id === preset.id)) merged.push(preset); + } + return merged.map((category, index) => normalizeTaskCategory(category, index)); +} + export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined { return categories.find((c) => c.id === id); } @@ -31,3 +83,16 @@ export function getCategoryById(categories: TaskCategory[], id: string): TaskCat export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] { return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder); } + +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; +} diff --git a/apps/web/lib/version-plan-workflow.test.ts b/apps/web/lib/version-plan-workflow.test.ts new file mode 100644 index 0000000..6356031 --- /dev/null +++ b/apps/web/lib/version-plan-workflow.test.ts @@ -0,0 +1,85 @@ +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', + linkedRequirementIds: ['r1'], + completedRequirementIds: [], + createdAt: '2026-06-25', + addedBy: 'PM', + ...patch, + }; +} + +test('does not allow research result submission when subtasks are incomplete', () => { + const state = getPlanCompletionState(plan({ + type: 'research', + tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }], + linkedRequirementIds: [], + })); + assert.equal(state.canSubmitResult, false); + assert.ok(state.missingReasons.includes('子任务未全部完成')); +}); + +test('requires product requirement coverage when linked requirements exist', () => { + const state = getPlanCompletionState(plan({})); + assert.equal(state.canSubmitResult, false); + assert.ok(state.missingReasons.includes('关联需求未全部覆盖')); +}); + +test('allows product result submission after coverage is complete without task checklist', () => { + const state = getPlanCompletionState(plan({ + completedRequirementIds: ['r1'], + })); + assert.equal(state.canSubmitResult, true); + assert.equal(state.canComplete, false); +}); + +test('allows completion only after result exists', () => { + const state = getPlanCompletionState(plan({ + completedRequirementIds: ['r1'], + resultType: 'link', + resultTitle: '原型', + resultUrl: 'https://example.com/prototype', + })); + assert.equal(state.canComplete, true); +}); + +test('ui plan does not require task checklist', () => { + const state = getPlanCompletionState(plan({ + type: 'ui', + completedRequirementIds: ['r1'], + })); + assert.equal(state.canSubmitResult, 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); +}); diff --git a/apps/web/lib/version-plan-workflow.ts b/apps/web/lib/version-plan-workflow.ts new file mode 100644 index 0000000..942e58c --- /dev/null +++ b/apps/web/lib/version-plan-workflow.ts @@ -0,0 +1,76 @@ +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'; +} + +function requiresChecklist(plan: VersionPlan): boolean { + return plan.type === 'research'; +} + +export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState { + const tasks = plan.tasks ?? []; + const checklistTotal = tasks.length; + const checklistCompleted = tasks.filter((task) => task.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 (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少子任务'); + if (requiresChecklist(plan) && 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); +} diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts index d335f8f..1367fa6 100644 --- a/apps/web/lib/version-plan.ts +++ b/apps/web/lib/version-plan.ts @@ -19,6 +19,7 @@ export interface VersionPlan { completedRequirementIds?: string[]; linkedRequirementIds?: string[]; resultType?: 'link' | 'file'; + resultTitle?: string; resultUrl?: string; resultFileName?: string; resultFileData?: string; @@ -28,6 +29,10 @@ export interface VersionPlan { createdAt: string; completedAt?: string; addedBy: string; + aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error'; + aiDecomposeAt?: string; + aiDecomposeBy?: string; + aiDecomposeError?: string; } export type PlanType = VersionPlan['type']; diff --git a/apps/web/package.json b/apps/web/package.json index e159f31..8b99aed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,11 +3,12 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "next dev --port 3000", - "build": "next build", + "dev": "node --max-old-space-size=4096 node_modules/next/dist/bin/next dev --port 3000", + "build": "node --max-old-space-size=4096 node_modules/next/dist/bin/next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/apps/web/scripts/run-node-tests.mjs b/apps/web/scripts/run-node-tests.mjs new file mode 100644 index 0000000..6860119 --- /dev/null +++ b/apps/web/scripts/run-node-tests.mjs @@ -0,0 +1,25 @@ +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); diff --git a/apps/web/stores/useAuthStore.ts b/apps/web/stores/useAuthStore.ts index a45d9d3..935da68 100644 --- a/apps/web/stores/useAuthStore.ts +++ b/apps/web/stores/useAuthStore.ts @@ -1,6 +1,9 @@ 'use client'; import { create } from 'zustand'; +import { loadServerData } from '@/lib/server-data'; +import type { Member } from '@/lib/members'; +import { useMemberStore } from './useMemberStore'; interface AuthUser { id: string; @@ -14,53 +17,63 @@ interface AuthUser { interface AuthState { user: AuthUser | null; isAuthenticated: boolean; - login: (phone: string, password: string, remember: boolean) => boolean; + login: (phone: string, password: string, remember: boolean) => Promise; logout: () => void; checkAuth: () => void; - refreshUser: () => void; + refreshUser: () => Promise; +} + +interface StoredMembersData { + members: Member[]; } const SESSION_KEY = 'ftb_auth_session'; const PERSIST_KEY = 'ftb_auth_persist'; +const FALLBACK_MEMBERS: Member[] = [ + { + id: 'm-8', + name: '\u8d85\u7ea7\u7ba1\u7406\u5458', + departmentId: 'dept-1', + roleId: 'role-admin', + phone: '13200132008', + email: 'chenshi@company.com', + password: 'Ftb@2024', + createdAt: '2024-01-01', + }, +]; + +async function loadMembersForAuth(): Promise { + try { + const stored = await loadServerData('members'); + if (stored?.members?.length) return stored.members; + } catch {} + + const inMemoryMembers = useMemberStore.getState().members; + return inMemoryMembers.length > 0 ? inMemoryMembers : FALLBACK_MEMBERS; +} + +function toAuthUser(member: Member): AuthUser { + return { + id: member.id, + name: member.name, + roleId: member.roleId, + departmentId: member.departmentId, + phone: member.phone, + email: member.email, + }; +} + export const useAuthStore = create((set) => ({ user: null, isAuthenticated: false, - login: (phone, password, remember) => { - const membersRaw = localStorage.getItem('ftb_members_v1'); - let members: any[] = []; - if (membersRaw) { - const parsed = JSON.parse(membersRaw); - members = parsed.members || []; - } - - // fallback: 如果 localStorage 没有数据或为空,使用默认 mock - if (members.length === 0) { - members = [ - { id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024' }, - { id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024' }, - { id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024' }, - { id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024' }, - { id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024' }, - { id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024' }, - { id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024' }, - { id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024' }, - ]; - } - - const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password); + login: async (phone, password, remember) => { + const members = await loadMembersForAuth(); + const member = members.find((m) => m.phone === phone && (m.password || 'Ftb@2024') === password); if (!member) return false; - const user: AuthUser = { - id: member.id, - name: member.name, - roleId: member.roleId, - departmentId: member.departmentId, - phone: member.phone, - email: member.email, - }; - + const user = toAuthUser(member); set({ user, isAuthenticated: true }); sessionStorage.setItem(SESSION_KEY, JSON.stringify(user)); if (remember) { @@ -90,16 +103,16 @@ export const useAuthStore = create((set) => ({ } }, - refreshUser: () => { + refreshUser: async () => { const cur = useAuthStore.getState().user; if (!cur) return; try { - const raw = localStorage.getItem('ftb_members_v1'); - if (!raw) return; - const parsed = JSON.parse(raw); - const m = parsed.members?.find((x: any) => x.id === cur.id); - if (!m) return; - const next: AuthUser = { id: m.id, name: m.name, roleId: m.roleId, departmentId: m.departmentId, phone: m.phone, email: m.email }; + const memoryMember = useMemberStore.getState().members.find((x) => x.id === cur.id); + const members = memoryMember ? [memoryMember] : await loadMembersForAuth(); + const member = members.find((x) => x.id === cur.id); + if (!member) return; + + const next = toAuthUser(member); useAuthStore.setState({ user: next }); sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); if (localStorage.getItem(PERSIST_KEY)) { diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts index 176556f..7532c8d 100644 --- a/apps/web/stores/useMemberStore.ts +++ b/apps/web/stores/useMemberStore.ts @@ -3,8 +3,7 @@ import { create } from 'zustand'; import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members'; import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members'; import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions'; - -const STORAGE_KEY = 'ftb_members_v1'; +import { loadServerData, saveServerData } from '@/lib/server-data'; const PRESET_DEPARTMENTS: Department[] = [ { id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' }, @@ -25,14 +24,7 @@ const PRESET_ROLES: RoleItem[] = [ ]; const MOCK_MEMBERS: Member[] = [ - { id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024', createdAt: '2024-01-10' }, - { id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024', createdAt: '2024-01-12' }, - { id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024', createdAt: '2024-01-15' }, - { id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024', createdAt: '2024-02-01' }, - { id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024', createdAt: '2024-02-10' }, - { id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024', createdAt: '2024-02-15' }, - { id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024', createdAt: '2024-03-01' }, - { id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' }, + { id: 'm-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' }, ]; interface MemberState { @@ -54,14 +46,13 @@ interface MemberState { deleteRole: (id: string) => void; } -function saveLocal(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} +function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) { + saveServerData('members', state).catch(() => {}); } -function loadLocal(): { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null { +async function loadStored(): Promise<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null> { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members'); } catch {} return null; } @@ -73,8 +64,8 @@ export const useMemberStore = create((set, get) => ({ passwordRule: DEFAULT_PASSWORD_RULE, loading: false, - fetchMembers: () => { - const cached = loadLocal(); + fetchMembers: async () => { + const cached = await loadStored(); if (cached) { // 迁移:旧 RoleItem 没 permissions 字段,补默认值 const migratedRoles = cached.roles.map((r) => { @@ -89,41 +80,41 @@ export const useMemberStore = create((set, get) => ({ updatePasswordRule: (rule) => { set({ passwordRule: rule }); - saveLocal({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }); + saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }); }, createDepartment: (data) => { const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const departments = [...get().departments, dept]; set({ departments }); - saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); }, updateDepartment: (id, data) => { const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d); set({ departments }); - saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); }, deleteDepartment: (id) => { const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id); set({ departments }); - saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }); }, createMember: (data) => { const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const members = [...get().members, member]; set({ members }); - saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); }, updateMember: (id, data) => { const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m); set({ members }); - saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); }, deleteMember: (id) => { const members = get().members.filter((m) => m.id !== id); set({ members }); - saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); }, createRole: (data) => { @@ -135,7 +126,7 @@ export const useMemberStore = create((set, get) => ({ }; const roles = [...get().roles, role]; set({ roles }); - saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); }, updateRole: (id, data) => { const roles = get().roles.map((r) => { @@ -148,11 +139,11 @@ export const useMemberStore = create((set, get) => ({ return { ...r, ...data }; }); set({ roles }); - saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); }, deleteRole: (id) => { const roles = get().roles.filter((r) => r.id !== id); set({ roles }); - saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); + saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); }, })); diff --git a/apps/web/stores/useOvertimeStore.ts b/apps/web/stores/useOvertimeStore.ts index 2ba105d..2b37f2b 100644 --- a/apps/web/stores/useOvertimeStore.ts +++ b/apps/web/stores/useOvertimeStore.ts @@ -3,8 +3,7 @@ import { create } from 'zustand'; import type { OvertimeRecord } from '@/lib/overtime'; import type { DictItem } from '@/lib/requirement'; import { calcDuration } from '@/lib/overtime'; - -const STORAGE_KEY = 'ftb_overtime_v1'; +import { loadServerData, saveServerData } from '@/lib/server-data'; const PRESET_REASONS: DictItem[] = [ { id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' }, @@ -20,19 +19,13 @@ const PRESET_REASONS: DictItem[] = [ { id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' }, ]; -const MOCK_RECORDS: OvertimeRecord[] = [ - { id: 'ot-1', projectId: 'p1', versionId: 'v2', person: '张三', startTime: '2024-12-10T19:00', endTime: '2024-12-10T22:30', duration: 3.5, reasonId: 'reason-3', createdAt: '2024-12-10' }, - { id: 'ot-2', projectId: 'p2', versionId: 'v5', person: '李四', startTime: '2024-12-11T18:30', endTime: '2024-12-11T21:00', duration: 2.5, reasonId: 'reason-1', createdAt: '2024-12-11' }, - { id: 'ot-3', projectId: 'p1', versionId: 'v2', person: '王五', startTime: '2024-12-12T19:00', endTime: '2024-12-12T23:00', duration: 4, reasonId: 'reason-4', createdAt: '2024-12-12' }, - { id: 'ot-4', projectId: 'p3', person: '张三', startTime: '2024-12-13T18:00', endTime: '2024-12-13T20:30', duration: 2.5, reasonId: 'reason-7', createdAt: '2024-12-13' }, - { id: 'ot-5', projectId: 'p2', versionId: 'v5', person: '赵六', startTime: '2024-12-14T19:30', endTime: '2024-12-15T01:00', duration: 5.5, reasonId: 'reason-5', remark: '线上紧急事故', createdAt: '2024-12-14' }, -]; +const MOCK_RECORDS: OvertimeRecord[] = []; interface OvertimeState { records: OvertimeRecord[]; reasons: DictItem[]; loading: boolean; - fetchRecords: () => void; + fetchRecords: () => Promise; createRecord: (data: Omit) => void; updateRecord: (id: string, data: Partial) => void; deleteRecord: (id: string) => void; @@ -41,14 +34,13 @@ interface OvertimeState { deleteReason: (id: string) => void; } -function saveLocal(data: { records: OvertimeRecord[]; reasons: DictItem[] }) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {} +function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) { + saveServerData('overtime', data).catch(() => {}); } -function loadLocal(): { records: OvertimeRecord[]; reasons: DictItem[] } | null { +async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime'); } catch {} return null; } @@ -58,8 +50,8 @@ export const useOvertimeStore = create((set, get) => ({ reasons: PRESET_REASONS, loading: false, - fetchRecords: () => { - const cached = loadLocal(); + fetchRecords: async () => { + const cached = await loadStored(); if (cached) set({ records: cached.records, reasons: cached.reasons }); }, @@ -73,7 +65,7 @@ export const useOvertimeStore = create((set, get) => ({ }; const records = [...get().records, record]; set({ records }); - saveLocal({ records, reasons: get().reasons }); + saveStored({ records, reasons: get().reasons }); }, updateRecord: (id, data) => { @@ -86,31 +78,31 @@ export const useOvertimeStore = create((set, get) => ({ return updated; }); set({ records }); - saveLocal({ records, reasons: get().reasons }); + saveStored({ records, reasons: get().reasons }); }, deleteRecord: (id) => { const records = get().records.filter((r) => r.id !== id); set({ records }); - saveLocal({ records, reasons: get().reasons }); + saveStored({ records, reasons: get().reasons }); }, addReason: (name) => { const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const reasons = [...get().reasons, item]; set({ reasons }); - saveLocal({ records: get().records, reasons }); + saveStored({ records: get().records, reasons }); }, updateReason: (id, name) => { const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r); set({ reasons }); - saveLocal({ records: get().records, reasons }); + saveStored({ records: get().records, reasons }); }, deleteReason: (id) => { const reasons = get().reasons.filter((r) => r.id !== id); set({ reasons }); - saveLocal({ records: get().records, reasons }); + saveStored({ records: get().records, reasons }); }, })); diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index f24757f..c76d5eb 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -3,6 +3,8 @@ import { create } from 'zustand'; import { Product } from '@ftb/shared'; import { api } from '@/lib/api'; +import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence'; +import { loadServerData, saveServerData } from '@/lib/server-data'; import type { Stage, Role } from '@/lib/stage'; import type { Priority, VersionLinks } from '@/lib/derive'; @@ -32,20 +34,7 @@ interface VersionItem { links?: VersionLinks; } -const MOCK_OVERVIEW: ProductOverview[] = [ - { - id: 'mock-1', - name: '翻台宝', - description: '餐饮 SaaS 主产品,覆盖门店运营管理全流程', - createdAt: '2024-01-01', - updatedAt: '2024-06-01', - projects: [ - { id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' }, - ], - versions: [], - _count: { requirements: 10, projects: 1, versions: 0 }, - }, -]; +const MOCK_OVERVIEW: ProductOverview[] = []; interface ProductWithCount { id: string; @@ -101,8 +90,8 @@ export const useProductStore = create((set, get) => ({ fetchOverview: async () => { if (get().overview.length > 0) return; - const cached = loadLocal(); - if (cached.length > 0) { + const cached = await loadStoredOverview(); + if (cached) { // 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地) set({ overview: cached, loading: false }); return; @@ -111,10 +100,11 @@ export const useProductStore = create((set, get) => ({ try { const overview = await api.get('/products/overview'); set({ overview, loading: false }); - saveLocal(overview); + if (shouldPersistRemoteOverview(overview)) { + saveStoredOverview(overview); + } } catch { set({ overview: MOCK_OVERVIEW, error: null, loading: false }); - saveLocal(MOCK_OVERVIEW); } }, @@ -147,7 +137,7 @@ export const useProductStore = create((set, get) => ({ }; const updated = [newProduct, ...get().overview]; set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); // 异步尝试同步远端(失败也无所谓,本地已更新) try { await api.post('/products', data); } catch {} }, @@ -161,7 +151,7 @@ export const useProductStore = create((set, get) => ({ p.id === id ? { ...p, ...data } : p, ); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); } }, @@ -174,14 +164,14 @@ export const useProductStore = create((set, get) => ({ products: get().products.filter((p) => p.id !== id), overview: updated, }); - saveLocal(updated); + saveStoredOverview(updated); }, reorderProducts: (ids) => { const map = new Map(get().overview.map((p) => [p.id, p])); const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[]; set({ overview: reordered }); - saveLocal(reordered); + saveStoredOverview(reordered); }, migrateAndDeleteProduct: (sourceId, targetId) => { @@ -204,7 +194,7 @@ export const useProductStore = create((set, get) => ({ .filter((p) => p.id !== sourceId) .map((p) => (p.id === targetId ? migratedTarget : p)); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, createProject: (productId, data) => { @@ -223,7 +213,7 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, createVersion: (productId, data) => { @@ -244,7 +234,7 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, updateVersion: (productId, versionId, data) => { @@ -258,7 +248,7 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, deleteVersion: (productId, versionId) => { @@ -271,7 +261,7 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, deleteProject: (productId, projectId) => { @@ -284,20 +274,17 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated }); - saveLocal(updated); + saveStoredOverview(updated); }, })); -const STORAGE_KEY = 'ftb_products_overview_v4'; - -function saveLocal(data: ProductOverview[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {} +function saveStoredOverview(data: ProductOverview[]) { + saveServerData('products-overview', data).catch(() => {}); } -function loadLocal(): ProductOverview[] { +async function loadStoredOverview(): Promise { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('products-overview'); } catch {} - return MOCK_OVERVIEW; + return null; } diff --git a/apps/web/stores/useRequirementStore.ts b/apps/web/stores/useRequirementStore.ts index d1f26a1..7d558b6 100644 --- a/apps/web/stores/useRequirementStore.ts +++ b/apps/web/stores/useRequirementStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Priority } from '@/lib/derive'; +import { loadServerData, saveServerData } from '@/lib/server-data'; interface RequirementState { requirements: Requirement[]; @@ -10,7 +11,7 @@ interface RequirementState { types: DictItem[]; platforms: DictItem[]; loading: boolean; - fetchRequirements: () => void; + fetchRequirements: () => Promise; createRequirement: (data: Omit) => void; updateRequirement: (id: string, data: Partial) => void; deleteRequirement: (id: string) => void; @@ -50,33 +51,17 @@ const PRESET_PLATFORMS: DictItem[] = [ // --- Mock requirements --- -const MOCK_REQUIREMENTS: Requirement[] = [ - { id: 'req-1', code: 'REQ-001', title: '值班排班自动生成', description: '根据员工可用时间自动排班,支持轮换规则', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' }, - { id: 'req-2', code: 'REQ-002', title: '值班交接确认', description: '交接班时双方确认签到,记录交接内容', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'internal', sourceTarget: '门店经理', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' }, - { id: 'req-3', code: 'REQ-003', title: '值班异常提醒', description: '迟到、缺勤自动推送提醒给店长', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P0', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌A', platforms: ['platform-1', 'platform-2'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-02' }, - { id: 'req-4', code: 'REQ-004', title: '值班日历视图', description: '以日历形式展示当月值班安排', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '张三', createdAt: '2024-06-03' }, - { id: 'req-5', code: 'REQ-005', title: '值班换班申请', description: '员工可发起换班申请,经理审批', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌B', platforms: ['platform-1'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-03' }, - { id: 'req-6', code: 'REQ-006', title: '值班统计报表', description: '按月/周统计各员工值班时长、缺勤次数', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-3', creator: '张三', createdAt: '2024-06-04' }, - { id: 'req-7', code: 'REQ-007', title: '多门店值班管理', description: '区域经理可查看管辖所有门店值班情况', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'XL', sourceType: 'management', sourceTarget: '区域总监', platforms: ['platform-1', 'platform-3'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-05' }, - { id: 'req-8', code: 'REQ-008', title: '值班备注功能', description: '排班时可添加备注(如特殊事项提醒)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P3', effort: 'S', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '李四', createdAt: '2024-06-05' }, - { id: 'req-9', code: 'REQ-009', title: '法定节假日排班规则', description: '节假日自动应用特殊排班规则(加班费标记)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'operation', sourceTarget: '合规部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' }, - { id: 'req-10', code: 'REQ-010', title: '值班模板管理', description: '创建值班模板供快速复用(如早中晚三班)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' }, -]; +const MOCK_REQUIREMENTS: Requirement[] = []; -// --- localStorage helpers --- +// --- Server persistence helpers --- -const STORAGE_KEY = 'ftb_requirements_v3'; - -function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch {} +function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) { + saveServerData('requirements', state).catch(() => {}); } -function loadLocal(): { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null { +async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements'); } catch {} return null; } @@ -91,8 +76,8 @@ export const useRequirementStore = create((set, get) => { platforms: PRESET_PLATFORMS, loading: false, - fetchRequirements: () => { - const cached = loadLocal(); + fetchRequirements: async () => { + const cached = await loadStored(); if (cached) { set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms }); } @@ -110,21 +95,21 @@ export const useRequirementStore = create((set, get) => { }; const updated = [...requirements, newReq]; set({ requirements: updated }); - saveLocal({ requirements: updated, sourceTargets, types, platforms }); + saveStored({ requirements: updated, sourceTargets, types, platforms }); }, updateRequirement: (id, data) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r)); set({ requirements: updated }); - saveLocal({ requirements: updated, sourceTargets, types, platforms }); + saveStored({ requirements: updated, sourceTargets, types, platforms }); }, deleteRequirement: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.filter((r) => r.id !== id); set({ requirements: updated }); - saveLocal({ requirements: updated, sourceTargets, types, platforms }); + saveStored({ requirements: updated, sourceTargets, types, platforms }); }, // --- SourceTarget dict --- @@ -133,21 +118,21 @@ export const useRequirementStore = create((set, get) => { const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...sourceTargets, item]; set({ sourceTargets: updated }); - saveLocal({ requirements, sourceTargets: updated, types, platforms }); + saveStored({ requirements, sourceTargets: updated, types, platforms }); }, updateSourceTarget: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s)); set({ sourceTargets: updated }); - saveLocal({ requirements, sourceTargets: updated, types, platforms }); + saveStored({ requirements, sourceTargets: updated, types, platforms }); }, deleteSourceTarget: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.filter((s) => s.id !== id); set({ sourceTargets: updated }); - saveLocal({ requirements, sourceTargets: updated, types, platforms }); + saveStored({ requirements, sourceTargets: updated, types, platforms }); }, // --- Type dict --- @@ -156,21 +141,21 @@ export const useRequirementStore = create((set, get) => { const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...types, item]; set({ types: updated }); - saveLocal({ requirements, sourceTargets, types: updated, platforms }); + saveStored({ requirements, sourceTargets, types: updated, platforms }); }, updateType: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.map((t) => (t.id === id ? { ...t, name } : t)); set({ types: updated }); - saveLocal({ requirements, sourceTargets, types: updated, platforms }); + saveStored({ requirements, sourceTargets, types: updated, platforms }); }, deleteType: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.filter((t) => t.id !== id); set({ types: updated }); - saveLocal({ requirements, sourceTargets, types: updated, platforms }); + saveStored({ requirements, sourceTargets, types: updated, platforms }); }, // --- Platform dict --- @@ -179,21 +164,21 @@ export const useRequirementStore = create((set, get) => { const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...platforms, item]; set({ platforms: updated }); - saveLocal({ requirements, sourceTargets, types, platforms: updated }); + saveStored({ requirements, sourceTargets, types, platforms: updated }); }, updatePlatform: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p)); set({ platforms: updated }); - saveLocal({ requirements, sourceTargets, types, platforms: updated }); + saveStored({ requirements, sourceTargets, types, platforms: updated }); }, deletePlatform: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.filter((p) => p.id !== id); set({ platforms: updated }); - saveLocal({ requirements, sourceTargets, types, platforms: updated }); + saveStored({ requirements, sourceTargets, types, platforms: updated }); }, }; }); diff --git a/apps/web/stores/useTaskCategoryStore.ts b/apps/web/stores/useTaskCategoryStore.ts index 458a431..820a4b2 100644 --- a/apps/web/stores/useTaskCategoryStore.ts +++ b/apps/web/stores/useTaskCategoryStore.ts @@ -1,25 +1,23 @@ 'use client'; import { create } from 'zustand'; import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; -import { PRESET_CATEGORIES } from '@/lib/task-category'; +import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category'; +import { loadServerData, saveServerData } from '@/lib/server-data'; -const STORAGE_KEY = 'ftb_task_categories_v1'; - -function saveLocal(items: TaskCategory[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {} +function saveStored(items: TaskCategory[]) { + saveServerData('task-categories', items).catch(() => {}); } -function loadLocal(): TaskCategory[] | null { +async function loadStored(): Promise { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('task-categories'); } catch {} return null; } interface TaskCategoryState { categories: TaskCategory[]; - fetchCategories: () => void; + fetchCategories: () => Promise; addCategory: (name: string, group: CategoryGroup, color?: string) => void; updateCategory: (id: string, data: Partial) => void; deleteCategory: (id: string) => boolean; @@ -28,15 +26,16 @@ interface TaskCategoryState { export const useTaskCategoryStore = create((set, get) => ({ categories: PRESET_CATEGORIES, - fetchCategories: () => { - const cached = loadLocal(); - if (cached) set({ categories: cached }); + fetchCategories: async () => { + const cached = await loadStored(); + if (cached) set({ categories: normalizeTaskCategories(cached) }); }, addCategory: (name, group, color) => { const list = get().categories; const item: TaskCategory = { id: `cat-${Date.now()}`, + code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').replace(/^_+|_+$/g, '') || `cat_${Date.now()}`, name, group, color, @@ -45,13 +44,13 @@ export const useTaskCategoryStore = create((set, get) => ({ }; const updated = [...list, item]; set({ categories: updated }); - saveLocal(updated); + saveStored(updated); }, updateCategory: (id, data) => { const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c)); set({ categories: updated }); - saveLocal(updated); + saveStored(updated); }, deleteCategory: (id) => { @@ -59,7 +58,7 @@ export const useTaskCategoryStore = create((set, get) => ({ if (!target || target.isSystem) return false; const updated = get().categories.filter((c) => c.id !== id); set({ categories: updated }); - saveLocal(updated); + saveStored(updated); return true; }, })); diff --git a/apps/web/stores/useTaskWorklogStore.ts b/apps/web/stores/useTaskWorklogStore.ts index acc1981..e018453 100644 --- a/apps/web/stores/useTaskWorklogStore.ts +++ b/apps/web/stores/useTaskWorklogStore.ts @@ -1,24 +1,22 @@ 'use client'; import { create } from 'zustand'; import type { TaskWorklog } from '@/lib/task-worklog'; +import { loadServerData, saveServerData } from '@/lib/server-data'; -const STORAGE_KEY = 'ftb_task_worklogs_v1'; - -function saveLocal(items: TaskWorklog[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {} +function saveStored(items: TaskWorklog[]) { + saveServerData('task-worklogs', items).catch(() => {}); } -function loadLocal(): TaskWorklog[] | null { +async function loadStored(): Promise { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('task-worklogs'); } catch {} return null; } interface TaskWorklogState { worklogs: TaskWorklog[]; - fetchWorklogs: () => void; + fetchWorklogs: () => Promise; addWorklog: (data: Omit) => void; deleteWorklog: (id: string) => void; getActualHours: (taskId: string) => number; @@ -27,8 +25,8 @@ interface TaskWorklogState { export const useTaskWorklogStore = create((set, get) => ({ worklogs: [], - fetchWorklogs: () => { - const cached = loadLocal(); + fetchWorklogs: async () => { + const cached = await loadStored(); if (cached) set({ worklogs: cached }); }, @@ -40,13 +38,13 @@ export const useTaskWorklogStore = create((set, get) => ({ }; const updated = [...get().worklogs, item]; set({ worklogs: updated }); - saveLocal(updated); + saveStored(updated); }, deleteWorklog: (id) => { const updated = get().worklogs.filter((w) => w.id !== id); set({ worklogs: updated }); - saveLocal(updated); + saveStored(updated); }, getActualHours: (taskId) => { diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts index 891b26a..c57d601 100644 --- a/apps/web/stores/useVersionPlanStore.ts +++ b/apps/web/stores/useVersionPlanStore.ts @@ -1,37 +1,36 @@ 'use client'; import { create } from 'zustand'; import type { VersionPlan, PlanType } from '@/lib/version-plan'; - -const STORAGE_KEY = 'ftb_version_plans_v1'; +import { loadServerData, saveServerData } from '@/lib/server-data'; +import { getPlanCompletionState } from '@/lib/version-plan-workflow'; const MOCK_PLANS: VersionPlan[] = []; -function saveLocal(plans: VersionPlan[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {} +function saveStored(plans: VersionPlan[]) { + saveServerData('version-plans', plans).catch(() => {}); } -function loadLocal(): VersionPlan[] | null { +async function loadStored(): Promise { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('version-plans'); } catch {} return null; } interface VersionPlanState { plans: VersionPlan[]; - fetchPlans: () => void; + fetchPlans: () => Promise; createPlan: (data: Omit) => void; updatePlan: (id: string, data: Partial) => void; - completePlan: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void; + completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string }; deletePlan: (id: string) => void; } export const useVersionPlanStore = create((set, get) => ({ plans: MOCK_PLANS, - fetchPlans: () => { - const cached = loadLocal(); + fetchPlans: async () => { + const cached = await loadStored(); if (cached) set({ plans: cached }); }, @@ -39,7 +38,7 @@ export const useVersionPlanStore = create((set, get) => ({ const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const plans = [...get().plans, plan]; set({ plans }); - saveLocal(plans); + saveStored(plans); }, updatePlan: (id, data) => { @@ -56,18 +55,30 @@ export const useVersionPlanStore = create((set, get) => ({ return { ...p, ...patch }; }); set({ plans }); - saveLocal(plans); + saveStored(plans); }, completePlan: (id, result) => { - const plans = get().plans.map((p) => p.id === id ? { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() } : p); + 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 }); - saveLocal(plans); + saveStored(plans); + return response; }, deletePlan: (id) => { const plans = get().plans.filter((p) => p.id !== id); set({ plans }); - saveLocal(plans); + saveStored(plans); }, })); diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json new file mode 100644 index 0000000..1fcb9fc --- /dev/null +++ b/apps/web/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "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"] +} diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..ae35005 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,101 @@ +# 术语表 + +按字母/拼音首字母排序。同一术语只在系统的一个语境里有定义,避免歧义。 + +## A + +### Agent +本系统中指 AI 代理 — 自主完成某项分析/拆解工作的 LLM 调用单元,输出**草案**而非最终数据。所有 Agent 定义见 `agent-spec.md`。 + +### AI 草案 (AI Draft) +由 Agent 生成、未经用户确认的实体,带 `aiDraft: true` 标记。视觉上在列表里有紫色左边线和「AI 草案」徽章。用户编辑保存后标记自动清除。 + +### Assignee +任务负责人。在 DevTask / TestCase / Bug 中表示当前承接此任务/用例/缺陷的人员名(不是用户 ID,是 `member.name`)。 + +## B + +### Bug +缺陷实体。直接挂在版本上(`versionId` 必填),可追溯到 TestCase(`testCaseId` 可选)。状态机:`open → fixing → fixed → verifying → closed/rejected`。 + +## C + +### CapsuleStages +版本详情页的胶囊式阶段进度条组件。展示调研 → 产品方案 → UI → 开发 → 测试 → 已发布的阶段流转。 + +## D + +### DevTask +开发任务实体。状态机:`todo → in_progress → testing → submitted`。`submitted` 是终态(开发交付完成)。 + +### Drawer +侧边抽屉式弹层。系统中所有详情都用 Drawer 呈现,统一 `shadow-2xl`,可在内部完成状态流转。 + +## E + +### 引擎(Engine) +跨模块的纯函数派生层,避免 store 互相调用。 +- `linkage-engine.ts`:从 DevTask 状态派生需求"实际进度" +- `workspace-engine.ts`:聚合所有 store 数据为统一 WorkItem + +## P + +### Plan / VersionPlan +版本下的计划任务(调研 / 产品方案 / UI 设计三类)。状态机:`pending → in_progress → completed`。完成时提交「成果」(链接或文件 + 标题)。 + +### Priority +优先级标识:P0 / P1 / P2 / P3 / P4。P0 最高,P4 最低。 + +### Product +产品 — 顶层组织容器。一个产品包含多个项目和需求池。 + +### Project +项目 — 产品下的子单位。一个项目包含多个版本。 + +### Prototype +原型 — 产品方案产物,通常是 Axure 导出的 HTML 集合。系统中只保存 URL,不缓存内容。 + +### Prototype Note +原型批注。Axure 等工具中以 `QY0007` 等编号形式标注的业务规则说明。Agent 拆解任务时的核心输入之一。 + +## Q + +### Quality Loop(质量闭环) +TestCase + Bug 共同构成的质量验收环路。版本是否能发布的判断维度。 + +## R + +### Reference(引用) +任务/用例的来源标记,类型为 `requirement` / `prototype_note` / `external`。所有任务/用例必须至少有 1 条引用(人工或 AI 生成都需要)。 + +### Requirement +需求实体。语义层(解释为什么做),不驱动流程。状态机:`pending_review → adopted → planned → developing → testing → released → closed`(rejected 可回 pending_review)。 + +## S + +### Sprint +迭代 — 项目内的时间盒(1-4 周)。当前 V1 暂未启用 Sprint 实体,使用 Version 替代。 + +### Stage(阶段) +版本执行流程的语义段:调研 / 产品方案 / UI 设计 / 开发 / 测试 / 已发布。每个 Stage 可派生进度。 + +### 超管 +拥有 `role.permissions` 含 `'*'` 的用户。可见所有版本(绕过 `version.members` 过滤),可执行所有操作。 + +## T + +### TestCase +测试用例。主归属版本(`versionId` 必填),需求是可选标签(`requirementId` 可选)。 + +## V + +### Version +版本 — 项目下的发布单位。**所有任务都归属版本**,是状态流转的执行主线。 + +### VersionWithContext +派生类型 — 在 Version 基础上扩展 productId/productName/projectId/projectName 等上下文字段,用于跨页面展示。 + +## W + +### WorkItem +工作台聚合实体。把 DevTask / TestCase / Bug / Plan 等不同实体统一为同一形状,给"与我相关"页面用。 diff --git a/docs/superpowers/plans/2026-06-25-workflow-effort-engine.md b/docs/superpowers/plans/2026-06-25-workflow-effort-engine.md new file mode 100644 index 0000000..253348d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-workflow-effort-engine.md @@ -0,0 +1,1499 @@ +# Workflow and Effort Engine 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:** Make DevTask, TestCase, and Bug status changes single-item only, move timestamp rules into workflow helpers, add TestCase estimates and weighted effort progress, and tighten AI estimates for AI-assisted delivery. + +**Architecture:** Add small pure rule helpers in `apps/web/lib/` before changing stores or UI. Stores will call workflow helpers for single-item commands; UI will display derived effort from helpers instead of encoding status or time rules inline. AI estimate policy stays as a pure helper used when adopting AI drafts, while the server prompt/schema defines the upstream contract. + +**Tech Stack:** Next.js 14, React, Zustand, TypeScript, Node `node:test` for web pure-function tests, NestJS/Jest for AI schema tests, existing PostgreSQL AppData persistence. + +--- + +## File Structure + +- Create `apps/web/lib/dev-task-workflow.ts`: DevTask single-item transition helper and create normalization. +- Create `apps/web/lib/dev-task-workflow.test.ts`: DevTask transition, timestamp, and AI draft default tests. +- Create `apps/web/lib/test-case-workflow.ts`: TestCase single-item transition helper and create normalization. +- Create `apps/web/lib/test-case-workflow.test.ts`: TestCase transition, timestamp, and reason cleanup tests. +- Create `apps/web/lib/bug-workflow.ts`: Bug single-item transition helper with log patch generation. +- Create `apps/web/lib/bug-workflow.test.ts`: Bug transition and timestamp tests. +- Create `apps/web/lib/work-effort-engine.ts`: shared estimate, actual, weighted progress aggregation. +- Create `apps/web/lib/work-effort-engine.test.ts`: weighted progress and rounding tests. +- Create `apps/web/lib/ai-estimation-policy.ts`: strict estimate ranges for AI-assisted dev/test work. +- Create `apps/web/lib/ai-estimation-policy.test.ts`: estimate clamp and default tests. +- Modify `apps/web/lib/dev-task.ts`: add `estimateHours`, make estimates prefer explicit hours, remove `estimateHours` from legacy discard marker. +- Modify `apps/web/lib/dev-task-transitions.ts`: keep delay detection only; stop using auto-start helpers. +- Modify `apps/web/stores/useDevTaskStore.ts`: remove auto-start scan, use workflow helper for `changeStatus`, normalize created tasks. +- Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx`: persist computed `estimateHours`. +- Modify `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`: import transitions from workflow constants if needed. +- Modify `apps/web/lib/test-case.ts`: add `estimateHours`, labels `待测试/测试中`, derived estimate/actual/progress helpers. +- Modify `apps/web/stores/useTestCaseStore.ts`: normalize `estimateHours`, use workflow helper for `changeStatus`. +- Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: add strict estimate input with 0.25h step. +- Modify `apps/web/components/test-case/TestCaseTab.tsx`: show total estimate, actual, weighted completion, and pass rate. +- Modify `apps/web/components/test-case/TestCaseRow.tsx`: show actual/estimate per row. +- Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show estimate, actual start, completed time, and actual/estimate. +- Modify `apps/web/stores/useBugStore.ts`: use Bug workflow helper. +- Modify `packages/shared/src/agent.ts`: add `estimateHours` to `AgentTestCaseDraft`. +- Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require TestCase estimate and tighten estimate rules. +- Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert TestCase estimate schema. +- Modify `apps/web/components/version/DecomposeReportModal.tsx`: clamp AI estimates, use `addWorkHours`, persist TestCase estimates. + +--- + +### Task 1: Add Shared Work Effort Engine + +**Files:** +- Create: `apps/web/lib/work-effort-engine.ts` +- Create: `apps/web/lib/work-effort-engine.test.ts` + +- [ ] **Step 1: Write failing effort tests** + +Create `apps/web/lib/work-effort-engine.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine'; + +test('roundHalfHour rounds to nearest half hour', () => { + assert.equal(roundHalfHour(0.24), 0); + assert.equal(roundHalfHour(0.25), 0.5); + assert.equal(roundHalfHour(1.24), 1); + assert.equal(roundHalfHour(1.25), 1.5); +}); + +test('aggregateWorkEffort calculates weighted progress by estimate', () => { + const result = aggregateWorkEffort([ + { estimateHours: 1, actualHours: 0, progress: 0 }, + { estimateHours: 3, actualHours: 1.25, progress: 100 }, + ]); + + assert.equal(result.estimateHours, 4); + assert.equal(result.actualHours, 1.5); + assert.equal(result.progress, 75); +}); + +test('aggregateWorkEffort falls back to item average when estimates are zero', () => { + const result = aggregateWorkEffort([ + { estimateHours: 0, actualHours: 0, progress: 50 }, + { estimateHours: 0, actualHours: 0, progress: 100 }, + ]); + + assert.equal(result.progress, 75); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `work-effort-engine.ts` does not exist. + +- [ ] **Step 3: Implement effort engine** + +Create `apps/web/lib/work-effort-engine.ts`: + +```ts +export interface WorkEffortItem { + estimateHours: number; + actualHours: number; + progress: number; +} + +export interface WorkEffortSummary { + estimateHours: number; + actualHours: number; + progress: number; +} + +export function roundHalfHour(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 0; + return Math.round(hours * 2) / 2; +} + +export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary { + if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 }; + + const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0)); + const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0)); + + if (estimateHours <= 0) { + const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length); + return { estimateHours, actualHours, progress }; + } + + const weighted = items.reduce((sum, item) => { + const estimate = Math.max(0, item.estimateHours || 0); + return sum + estimate * item.progress; + }, 0); + + return { + estimateHours, + actualHours, + progress: Math.round(weighted / estimateHours), + }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/lib/work-effort-engine.ts apps/web/lib/work-effort-engine.test.ts +git commit -m "feat(工时): 增加通用工时汇总引擎" +``` + +--- + +### Task 2: Add DevTask Workflow Helper and Stop Auto Start + +**Files:** +- Create: `apps/web/lib/dev-task-workflow.ts` +- Create: `apps/web/lib/dev-task-workflow.test.ts` +- Modify: `apps/web/lib/dev-task.ts` +- Modify: `apps/web/lib/dev-task-transitions.ts` +- Modify: `apps/web/stores/useDevTaskStore.ts` +- Modify: `apps/web/components/dev-task/DevTaskCreateModal.tsx` + +- [ ] **Step 1: Write failing workflow tests** + +Create `apps/web/lib/dev-task-workflow.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { DevTask } from './dev-task'; +import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow'; + +function task(patch: Partial = {}): DevTask { + return { + id: 'task-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: '实现拖拽排序', + categoryId: 'cat-frontend-interaction', + assigneeId: 'Alice', + priority: 'P2', + expectedStartAt: '2026-06-25T01:00:00.000Z', + expectedEndAt: '2026-06-25T02:00:00.000Z', + status: 'todo', + isBlocked: false, + createdBy: 'PM', + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + ...patch, + }; +} + +test('normalizeDevTaskOnCreate forces todo and clears actual timestamps for AI drafts', () => { + const normalized = normalizeDevTaskOnCreate(task({ + status: 'in_progress', + actualStartAt: '2026-06-25T01:00:00.000Z', + actualEndAt: '2026-06-25T02:00:00.000Z', + aiDraft: true, + })); + + assert.equal(normalized.status, 'todo'); + assert.equal(normalized.actualStartAt, undefined); + assert.equal(normalized.actualEndAt, undefined); +}); + +test('todo to in_progress writes actualStartAt from manual click time', () => { + const result = applyDevTaskTransition(task(), 'in_progress', { + now: new Date('2026-06-25T03:30:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.status, 'in_progress'); + assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z'); +}); + +test('in_progress to testing does not write actualEndAt', () => { + const result = applyDevTaskTransition(task({ + status: 'in_progress', + actualStartAt: '2026-06-25T03:30:00.000Z', + }), 'testing', { + now: new Date('2026-06-25T04:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.actualEndAt, undefined); +}); + +test('testing to submitted writes actualEndAt', () => { + const result = applyDevTaskTransition(task({ + status: 'testing', + actualStartAt: '2026-06-25T03:30:00.000Z', + }), 'submitted', { + now: new Date('2026-06-25T05:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z'); +}); + +test('invalid transition is rejected', () => { + const result = applyDevTaskTransition(task(), 'submitted', { + now: new Date('2026-06-25T05:00:00.000Z'), + }); + + assert.equal(result.ok, false); + assert.match(result.message || '', /不允许/); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `dev-task-workflow.ts` does not exist. + +- [ ] **Step 3: Implement DevTask workflow helper** + +Create `apps/web/lib/dev-task-workflow.ts`: + +```ts +import type { DevTask, DevTaskStatus } from './dev-task'; +import { canTransition } from './dev-task'; + +export interface DevTaskWorkflowResult { + ok: boolean; + patch?: Partial; + message?: string; +} + +export interface DevTaskTransitionOptions { + now?: Date; + delayReason?: string; +} + +export function normalizeDevTaskOnCreate(task: DevTask): DevTask { + return { + ...task, + status: 'todo', + actualStartAt: undefined, + actualEndAt: undefined, + }; +} + +export function applyDevTaskTransition( + task: DevTask, + to: DevTaskStatus, + options: DevTaskTransitionOptions = {}, +): DevTaskWorkflowResult { + if (!canTransition(task.status, to)) { + return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` }; + } + + const nowIso = (options.now ?? new Date()).toISOString(); + const patch: Partial = { + status: to, + aiDraft: false, + }; + + if (to === 'in_progress' && !task.actualStartAt) { + patch.actualStartAt = nowIso; + if (options.delayReason?.trim()) patch.delayReason = options.delayReason.trim(); + } + + if (to === 'submitted') { + patch.actualEndAt = nowIso; + } + + return { ok: true, patch }; +} +``` + +- [ ] **Step 4: Add explicit DevTask estimate support** + +Modify `apps/web/lib/dev-task.ts`: + +```ts +export interface DevTask { + // existing fields... + expectedStartAt: string; + expectedEndAt: string; + estimateHours?: number; + actualStartAt?: string; + actualEndAt?: string; + // existing fields... +} +``` + +Change `getEstimateHours`: + +```ts +export function getEstimateHours(task: DevTask): number { + if (typeof task.estimateHours === 'number' && task.estimateHours > 0) { + return Math.round(task.estimateHours * 2) / 2; + } + if (!task.expectedStartAt || !task.expectedEndAt) return 0; + return calcWorkHours(task.expectedStartAt, task.expectedEndAt); +} +``` + +Change `isLegacyTask` by removing `estimateHours` from the legacy marker list: + +```ts +export function isLegacyTask(t: any): boolean { + if (!t || typeof t !== 'object') return false; + return ( + 'startDate' in t || + 'dueDate' in t || + 'completedAt' in t || + 'actualHours' in t || + 'overdueReason' in t + ); +} +``` + +- [ ] **Step 5: Remove auto-start from DevTask fetch** + +Modify `apps/web/stores/useDevTaskStore.ts` imports: + +```ts +import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; +``` + +Remove these imports: + +```ts +import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions'; +``` + +Replace `fetchTasks` with: + +```ts +fetchTasks: async () => { + const cached = await loadStored(); + set({ tasks: cached ?? [] }); +}, +``` + +Update `createTask`: + +```ts +const task: DevTask = normalizeDevTaskOnCreate({ + ...data, + id: `task-${Date.now()}`, + taskNo: generateTaskNo(list), + isBlocked: false, + createdAt: now, + updatedAt: now, +} as DevTask); +``` + +Update `changeStatus`: + +```ts +changeStatus: (id, to, opts) => { + const task = get().tasks.find((t) => t.id === id); + if (!task) return { ok: false, message: '任务不存在' }; + const result = applyDevTaskTransition(task, to, { + now: new Date(), + delayReason: opts?.delayReason, + }); + if (!result.ok || !result.patch) return { ok: false, message: result.message }; + get().updateTask(id, result.patch); + return { ok: true }; +}, +``` + +- [ ] **Step 6: Keep delay helper but stop using auto-start helpers** + +Modify `apps/web/lib/dev-task-transitions.ts` so only `needsDelayReason` remains exported: + +```ts +import type { DevTask } from './dev-task'; + +export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean { + if (task.status !== 'todo' || !task.expectedStartAt) return false; + return now.getTime() > new Date(task.expectedStartAt).getTime(); +} +``` + +- [ ] **Step 7: Persist manual DevTask estimate** + +Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx` in `createTask` payload: + +```ts +estimateHours, +``` + +The payload section should include: + +```ts +expectedStartAt: expectedStartISO, +expectedEndAt: expectedEndISO, +estimateHours, +actualStartAt: undefined, +actualEndAt: undefined, +status: 'todo', +``` + +- [ ] **Step 8: Run verification** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass, and DevTask fetch no longer changes task status automatically. + +- [ ] **Step 9: Commit** + +```bash +git add apps/web/lib/dev-task-workflow.ts apps/web/lib/dev-task-workflow.test.ts apps/web/lib/dev-task.ts apps/web/lib/dev-task-transitions.ts apps/web/stores/useDevTaskStore.ts apps/web/components/dev-task/DevTaskCreateModal.tsx +git commit -m "feat(开发任务): 状态流转接入单条工作流" +``` + +--- + +### Task 3: Add TestCase Workflow, Estimates, and Weighted Progress + +**Files:** +- Create: `apps/web/lib/test-case-workflow.ts` +- Create: `apps/web/lib/test-case-workflow.test.ts` +- Modify: `apps/web/lib/test-case.ts` +- Modify: `apps/web/stores/useTestCaseStore.ts` + +- [ ] **Step 1: Write failing TestCase workflow tests** + +Create `apps/web/lib/test-case-workflow.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { TestCase } from './test-case'; +import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow'; + +function tc(patch: Partial = {}): TestCase { + return { + id: 'tc-1', + caseNo: 'TC-001', + versionId: 'version-1', + title: '拖拽排序正常', + description: '验证拖拽排序', + categoryId: 'cat-test-functional', + priority: 'P2', + status: 'pending', + estimateHours: 0.5, + createdBy: 'QA', + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + ...patch, + }; +} + +test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', () => { + const result = normalizeTestCaseOnCreate(tc({ + status: 'running', + startedAt: '2026-06-25T01:00:00.000Z', + completedAt: '2026-06-25T02:00:00.000Z', + })); + + assert.equal(result.status, 'pending'); + assert.equal(result.startedAt, undefined); + assert.equal(result.completedAt, undefined); +}); + +test('pending to running writes startedAt', () => { + const result = applyTestCaseTransition(tc(), 'running', { + now: new Date('2026-06-25T01:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z'); +}); + +test('running to passed writes completedAt', () => { + const result = applyTestCaseTransition(tc({ + status: 'running', + startedAt: '2026-06-25T01:00:00.000Z', + }), 'passed', { + now: new Date('2026-06-25T02:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.completedAt, '2026-06-25T02:00:00.000Z'); +}); + +test('failed back to running clears failure and completion reason', () => { + const result = applyTestCaseTransition(tc({ + status: 'failed', + startedAt: '2026-06-25T01:00:00.000Z', + completedAt: '2026-06-25T02:00:00.000Z', + failReason: '排序未保存', + }), 'running', { + now: new Date('2026-06-25T03:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.startedAt, undefined); + assert.equal(result.patch?.completedAt, undefined); + assert.equal(result.patch?.failReason, undefined); +}); + +test('invalid transition is rejected', () => { + const result = applyTestCaseTransition(tc(), 'passed', { + now: new Date('2026-06-25T02:00:00.000Z'), + }); + + assert.equal(result.ok, false); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `test-case-workflow.ts` does not exist. + +- [ ] **Step 3: Implement TestCase workflow helper** + +Create `apps/web/lib/test-case-workflow.ts`: + +```ts +import type { TestCase, TestCaseStatus } from './test-case'; +import { canTcTransition } from './test-case'; + +export interface TestCaseWorkflowResult { + ok: boolean; + patch?: Partial; + message?: string; +} + +export interface TestCaseTransitionOptions { + now?: Date; + failReason?: string; + blockReason?: string; +} + +export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase { + return { + ...testCase, + status: 'pending', + startedAt: undefined, + completedAt: undefined, + executedAt: undefined, + failReason: undefined, + blockReason: undefined, + }; +} + +export function applyTestCaseTransition( + testCase: TestCase, + to: TestCaseStatus, + options: TestCaseTransitionOptions = {}, +): TestCaseWorkflowResult { + if (!canTcTransition(testCase.status, to)) { + return { ok: false, message: `不允许从「${testCase.status}」流转到「${to}」` }; + } + + const nowIso = (options.now ?? new Date()).toISOString(); + const patch: Partial = { + status: to, + aiDraft: false, + executedAt: nowIso, + }; + + if (to === 'running') { + if (!testCase.startedAt) patch.startedAt = nowIso; + patch.completedAt = undefined; + patch.failReason = undefined; + patch.blockReason = undefined; + } + + if (to === 'passed' || to === 'failed' || to === 'blocked') { + patch.completedAt = nowIso; + } + + if (to === 'failed' && options.failReason?.trim()) patch.failReason = options.failReason.trim(); + if (to === 'blocked' && options.blockReason?.trim()) patch.blockReason = options.blockReason.trim(); + + return { ok: true, patch }; +} +``` + +- [ ] **Step 4: Add TestCase estimate and weighted helpers** + +Modify `apps/web/lib/test-case.ts`: + +```ts +import { aggregateWorkEffort } from './work-effort-engine'; +``` + +Add `estimateHours?: number`: + +```ts +export interface TestCase { + // existing fields... + status: TestCaseStatus; + estimateHours?: number; + startedAt?: string; + completedAt?: string; + // existing fields... +} +``` + +Change status labels: + +```ts +export const TEST_CASE_STATUS_LABEL: Record = { + pending: '待测试', + running: '测试中', + passed: '通过', + failed: '不通过', + blocked: '阻塞', +}; +``` + +Update `normalizeTestCase`: + +```ts +estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5, +``` + +Add progress and estimate helpers: + +```ts +export const TEST_CASE_STATUS_PROGRESS: Record = { + pending: 0, + running: 50, + passed: 100, + failed: 100, + blocked: 100, +}; + +export function getTestCaseEstimateHours(tc: TestCase): number { + return typeof tc.estimateHours === 'number' && tc.estimateHours > 0 + ? Math.round(tc.estimateHours * 2) / 2 + : 0.5; +} + +export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } { + const summary = aggregateWorkEffort(cases.map((c) => ({ + estimateHours: getTestCaseEstimateHours(c), + actualHours: getTestCaseActualHours(c, now), + progress: TEST_CASE_STATUS_PROGRESS[c.status], + }))); + return { estimate: summary.estimateHours, actual: summary.actualHours }; +} +``` + +Update `calcTestProgress` to keep the existing return shape but make `completionRate` weighted: + +```ts +export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } { + const total = cases.length; + if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 }; + const passed = cases.filter((c) => c.status === 'passed').length; + const failed = cases.filter((c) => c.status === 'failed').length; + const blocked = cases.filter((c) => c.status === 'blocked').length; + const executed = passed + failed + blocked; + const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0; + const effort = aggregateWorkEffort(cases.map((c) => ({ + estimateHours: getTestCaseEstimateHours(c), + actualHours: getTestCaseActualHours(c), + progress: TEST_CASE_STATUS_PROGRESS[c.status], + }))); + return { total, executed, passed, failed, blocked, passRate, completionRate: effort.progress }; +} +``` + +- [ ] **Step 5: Update TestCase store to use workflow** + +Modify `apps/web/stores/useTestCaseStore.ts` imports: + +```ts +import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; +``` + +Update `createTestCase`: + +```ts +const tc: TestCase = normalizeTestCaseOnCreate({ + ...data, + id: `tc-${Date.now()}`, + caseNo: generateCaseNo(list), + status: 'pending', + estimateHours: data.estimateHours ?? 0.5, + createdAt: now, + updatedAt: now, +} as TestCase); +``` + +Update `changeStatus`: + +```ts +changeStatus: (id, to, extra) => { + const tc = get().testCases.find((c) => c.id === id); + if (!tc) return { ok: false, message: '用例不存在' }; + const result = applyTestCaseTransition(tc, to, { + now: new Date(), + failReason: extra?.failReason, + blockReason: extra?.blockReason, + }); + if (!result.ok || !result.patch) return { ok: false, message: result.message }; + get().updateTestCase(id, result.patch); + return { ok: true }; +}, +``` + +- [ ] **Step 6: Run verification** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/lib/test-case-workflow.ts apps/web/lib/test-case-workflow.test.ts apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts +git commit -m "feat(测试用例): 状态流转接入单条工作流" +``` + +--- + +### Task 4: Add Bug Workflow Helper + +**Files:** +- Create: `apps/web/lib/bug-workflow.ts` +- Create: `apps/web/lib/bug-workflow.test.ts` +- Modify: `apps/web/stores/useBugStore.ts` + +- [ ] **Step 1: Write failing Bug workflow tests** + +Create `apps/web/lib/bug-workflow.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Bug } from './bug'; +import { applyBugTransition } from './bug-workflow'; + +function bug(patch: Partial = {}): Bug { + return { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'version-1', + testCaseId: 'tc-1', + title: '排序未保存', + description: '拖拽后刷新丢失', + severity: 'major', + priority: 'P1', + reportedBy: 'QA', + assigneeId: 'Dev', + status: 'open', + logs: [], + createdAt: '2026-06-25T00:00:00.000Z', + updatedAt: '2026-06-25T00:00:00.000Z', + ...patch, + }; +} + +test('open to fixing changes only one bug patch', () => { + const result = applyBugTransition(bug(), 'fixing', 'Dev', { + now: new Date('2026-06-25T01:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.status, 'fixing'); + assert.equal(result.patch?.logs?.length, 1); +}); + +test('fixing to fixed writes resolvedAt and resolution', () => { + const result = applyBugTransition(bug({ status: 'fixing' }), 'fixed', 'Dev', { + now: new Date('2026-06-25T02:00:00.000Z'), + resolution: '补充保存接口', + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.resolvedAt, '2026-06-25T02:00:00.000Z'); + assert.equal(result.patch?.resolution, '补充保存接口'); +}); + +test('verifying to closed writes closedAt', () => { + const result = applyBugTransition(bug({ status: 'verifying' }), 'closed', 'QA', { + now: new Date('2026-06-25T03:00:00.000Z'), + }); + + assert.equal(result.ok, true); + assert.equal(result.patch?.closedAt, '2026-06-25T03:00:00.000Z'); +}); + +test('invalid bug transition is rejected', () => { + const result = applyBugTransition(bug(), 'closed', 'QA'); + assert.equal(result.ok, false); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `bug-workflow.ts` does not exist. + +- [ ] **Step 3: Implement Bug workflow helper** + +Create `apps/web/lib/bug-workflow.ts`: + +```ts +import type { Bug, BugLog, BugStatus } from './bug'; +import { canBugTransition } from './bug'; + +export interface BugTransitionOptions { + now?: Date; + resolution?: string; +} + +export interface BugWorkflowResult { + ok: boolean; + patch?: Partial; + message?: string; +} + +function makeBugLog( + action: BugLog['action'], + operator: string, + nowIso: string, + from?: string, + to?: string, + remark?: string, +): BugLog { + return { + id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + action, + fromValue: from, + toValue: to, + operator, + remark, + createdAt: nowIso, + }; +} + +export function applyBugTransition( + bug: Bug, + to: BugStatus, + operator: string, + options: BugTransitionOptions = {}, +): BugWorkflowResult { + if (!canBugTransition(bug.status, to)) { + return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` }; + } + + const nowIso = (options.now ?? new Date()).toISOString(); + const patch: Partial = { status: to }; + + if (to === 'fixed') patch.resolvedAt = nowIso; + if (to === 'closed') patch.closedAt = nowIso; + if (options.resolution?.trim()) patch.resolution = options.resolution.trim(); + + const log = makeBugLog( + to === 'fixed' ? 'resolve' : 'status_change', + operator, + nowIso, + bug.status, + to, + options.resolution, + ); + patch.logs = [...(bug.logs || []), log]; + + return { ok: true, patch }; +} +``` + +- [ ] **Step 4: Use workflow in Bug store** + +Modify `apps/web/stores/useBugStore.ts` imports: + +```ts +import { applyBugTransition } from '@/lib/bug-workflow'; +``` + +Keep local `makeLog` for create/transfer, or rename it to `makeStoreLog` so it does not conflict. + +Replace `changeStatus`: + +```ts +changeStatus: (id, to, operator, extra) => { + const bug = get().bugs.find((b) => b.id === id); + if (!bug) return { ok: false, message: 'Bug不存在' }; + const result = applyBugTransition(bug, to, operator, { + now: new Date(), + resolution: extra?.resolution, + }); + if (!result.ok || !result.patch) return { ok: false, message: result.message }; + get().updateBug(id, result.patch); + return { ok: true }; +}, +``` + +- [ ] **Step 5: Run verification** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/lib/bug-workflow.ts apps/web/lib/bug-workflow.test.ts apps/web/stores/useBugStore.ts +git commit -m "feat(Bug): 状态流转接入单条工作流" +``` + +--- + +### Task 5: Add Strict AI Estimation Policy + +**Files:** +- Create: `apps/web/lib/ai-estimation-policy.ts` +- Create: `apps/web/lib/ai-estimation-policy.test.ts` + +- [ ] **Step 1: Write failing estimate policy tests** + +Create `apps/web/lib/ai-estimation-policy.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { clampDevEstimateHours, clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from './ai-estimation-policy'; + +test('clamps simple frontend interaction to AI-assisted range', () => { + assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1); + assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5); +}); + +test('keeps backend API within strict range', () => { + assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75); + assert.equal(clampDevEstimateHours('backend_api', 2), 1.5); +}); + +test('defaults and clamps test case estimates', () => { + assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5); + assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5); + assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5); + assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `ai-estimation-policy.ts` does not exist. + +- [ ] **Step 3: Implement estimate policy** + +Create `apps/web/lib/ai-estimation-policy.ts`: + +```ts +import type { AgentTaskCategoryCode } from '@ftb/shared'; + +type EstimateRange = { min: number; max: number; fallback: number }; + +const DEV_ESTIMATE_RANGES: Record = { + frontend_development: { min: 0.5, max: 1.5, fallback: 1 }, + frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 }, + backend_development: { min: 1, max: 3, fallback: 2 }, + backend_api: { min: 0.75, max: 1.5, fallback: 1 }, + database_schema: { min: 0.5, max: 0.5, fallback: 0.5 }, + api_integration: { min: 0.75, max: 1.5, fallback: 1 }, + data_processing: { min: 1, max: 2, fallback: 1.5 }, + implementation_support: { min: 0.5, max: 1.5, fallback: 1 }, + documentation: { min: 0.25, max: 0.5, fallback: 0.5 }, +}; + +const TEST_ESTIMATE_RANGES: Record = { + test_functional: { min: 0.25, max: 0.5, fallback: 0.5 }, + test_api: { min: 0.5, max: 1, fallback: 0.5 }, + test_exception: { min: 0.5, max: 1, fallback: 0.5 }, + test_compatibility: { min: 0.5, max: 1, fallback: 1 }, +}; + +function roundQuarterHour(hours: number): number { + return Math.round(hours * 4) / 4; +} + +function clampToRange(raw: number | undefined, range: EstimateRange): number { + const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback; + return roundQuarterHour(Math.min(range.max, Math.max(range.min, base))); +} + +export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number { + return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 }); +} + +export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number { + return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback; +} + +export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number { + return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional); +} +``` + +- [ ] **Step 4: Run tests** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/lib/ai-estimation-policy.ts apps/web/lib/ai-estimation-policy.test.ts +git commit -m "feat(AI): 增加严格估时策略" +``` + +--- + +### Task 6: Update TestCase UI for Estimates and Single-Item Flow + +**Files:** +- 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 estimate input to TestCase create modal** + +Modify `apps/web/components/test-case/TestCaseCreateModal.tsx` imports: + +```ts +import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; +import { findCategoryByCode } from '@/lib/task-category'; +``` + +Add state after `categoryId`: + +```ts +const selectedCategory = useMemo( + () => categories.find((category) => category.id === categoryId), + [categories, categoryId], +); +const [estimateHours, setEstimateHours] = useState(0.5); +``` + +Add a category change handler: + +```ts +const handleCategoryChange = (nextCategoryId: string) => { + setCategoryId(nextCategoryId); + const category = categories.find((c) => c.id === nextCategoryId); + setEstimateHours(getDefaultTestCaseEstimateHours(category?.code)); +}; +``` + +Use it in the task type select: + +```tsx + setEstimateHours(Number(e.target.value))} + onBlur={() => setEstimateHours(clampTestCaseEstimateHours(selectedCategory?.code, estimateHours))} + 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" + /> +
+``` + +Pass estimate to store: + +```ts +estimateHours: clampTestCaseEstimateHours(selectedCategory?.code, estimateHours), +``` + +- [ ] **Step 2: Update TestCase tab stats** + +Modify imports in `apps/web/components/test-case/TestCaseTab.tsx`: + +```ts +import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; +``` + +Add: + +```ts +const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases); +``` + +Replace the stat text: + +```tsx + + {stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)} + +``` + +Keep calendar/person metrics: + +```tsx +{tcManhours > 0 && ( + + 日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)} + +)} +``` + +- [ ] **Step 3: Update TestCase row hours display** + +Modify `apps/web/components/test-case/TestCaseRow.tsx`: + +```ts +import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; +``` + +Inside component: + +```ts +const estimateHours = getTestCaseEstimateHours(testCase); +const actualHours = getTestCaseActualHours(testCase); +``` + +Replace hours render: + +```tsx + + {actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`} + +``` + +- [ ] **Step 4: Update TestCase drawer time fields** + +Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx` imports: + +```ts +import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case'; +import { formatWorkHours } from '@/lib/work-hours'; +``` + +Remove: + +```ts +import { calcActualHoursByDates } from '@/lib/dev-task'; +``` + +Add: + +```ts +const estimateHours = getTestCaseEstimateHours(tc); +const actualHours = getTestCaseActualHours(tc); +``` + +Replace the testing duration line: + +```tsx +
预估耗时:{formatWorkHours(estimateHours)}
+{tc.startedAt &&
实际耗时:{formatWorkHours(actualHours)}
} +``` + +- [ ] **Step 5: Verify no batch status actions exist** + +Run: + +```bash +rg -n "batch|全部通过|全部不通过|全部提测|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web/components apps/web/stores +``` + +Expected: any result is either batch delete or plain display copy. No batch status transition remains. + +- [ ] **Step 6: Run verification** + +Run: + +```bash +pnpm --filter web type-check +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add 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 7: Update AI Contract and Adoption Estimates + +**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 TestCase draft type** + +Modify `packages/shared/src/agent.ts`: + +```ts +export interface AgentTestCaseDraft { + title: string; + description: string; + categoryCode: AgentTaskCategoryCode; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + estimateHours: number; + references: AgentReference[]; +} +``` + +- [ ] **Step 2: Tighten prompt estimate policy** + +Modify `apps/server/src/modules/ai/prompts/decompose.ts` section `5. 工时估算(小时)` to: + +```ts +5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格) + - 简单前端字段、文案、展示调整: 0.25-0.5h + - 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h + - 拖拽排序并需要持久化接口: 1-1.5h + - 简单 CRUD 接口: 0.75-1.5h + - 数据库字段/索引调整: 0.5h + - 中等业务规则变更: 1.5-3h + - 简单功能测试用例执行: 0.25-0.5h + - API/异常/兼容性测试用例执行: 0.5-1h + - 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间 +``` + +Add TestCase schema property: + +```ts +estimateHours: { type: 'number' }, +``` + +Update TestCase required array: + +```ts +required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'], +``` + +- [ ] **Step 3: Update server schema test** + +Modify `apps/server/src/modules/ai/ai.service.spec.ts`: + +```ts +it('requires estimateHours for test case drafts', () => { + const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required; + assert(testCaseRequired.includes('estimateHours')); +}); +``` + +Use the existing assertion style in that file. If the file uses Jest `expect`, write: + +```ts +expect(testCaseRequired).toContain('estimateHours'); +``` + +- [ ] **Step 4: Clamp estimates when adopting AI drafts** + +Modify `apps/web/components/version/DecomposeReportModal.tsx` imports: + +```ts +import { addWorkHours } from '@/lib/work-hours'; +import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; +``` + +Replace AI dev estimate logic: + +```ts +const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours); +const startISO = new Date().toISOString(); +const endISO = addWorkHours(startISO, estimateHours); +``` + +Include in `createTask`: + +```ts +estimateHours, +expectedStartAt: startISO, +expectedEndAt: endISO, +actualStartAt: undefined, +actualEndAt: undefined, +status: 'todo', +``` + +For test cases: + +```ts +const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours); +``` + +Include in `createTestCase`: + +```ts +estimateHours, +``` + +Show estimate in TestCase draft preview: + +```tsx +{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h +``` + +- [ ] **Step 5: Run verification** + +Run: + +```bash +pnpm --filter server test +pnpm --filter web test +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 Manual Smoke Check + +**Files:** +- No source edits unless verification finds focused issues. + +- [ ] **Step 1: Run focused automated 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: Confirm no auto-start code path remains** + +Run: + +```bash +rg -n "findTasksToAutoStart|deriveActualStartAt|status: 'in_progress'.*actualStartAt|autoStart|自动切换扫描" apps/web +``` + +Expected: no active DevTask auto-start usage remains. A historical comment is acceptable only if it is not imported or called. + +- [ ] **Step 3: Confirm no batch status mutation remains** + +Run: + +```bash +rg -n "全部提测|全部通过|全部不通过|batch.*status|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web +``` + +Expected: no batch status action remains. Batch delete may remain if found under delete handlers. + +- [ ] **Step 4: Browser smoke check** + +If the dev server is already running, open `http://localhost:3000/versions` and verify on a version detail page: + +```text +1. AI adopted DevTask stays 待开发 and actual start/end are empty. +2. Clicking a single DevTask to 开发中 writes actual start only for that task. +3. Clicking a single DevTask to 已提测 writes actual end only for that task. +4. New TestCase starts 待测试, has estimateHours, and shows estimate in row/drawer. +5. Clicking a single TestCase to 测试中 writes startedAt only for that case. +6. Clicking passed/failed/blocked writes completedAt only for that case. +7. TestCase top progress shows weighted completion plus pre/actual hours. +8. Bug drawer still only changes the currently opened Bug. +``` + +- [ ] **Step 5: Inspect final diff** + +Run: + +```bash +git status --short +git diff --stat +``` + +Expected: only workflow/effort/AI estimate files and related UI/store files changed in this implementation series. Existing unrelated dirty files must not be reverted. + +- [ ] **Step 6: Commit verification fixes if needed** + +Only if Step 1-5 reveal small focused issues, fix them, rerun the failed command, then commit: + +```bash +git add +git commit -m "fix(工作流): 修正单条流转验证问题" +``` + +Do not create an empty commit. + +--- + +## Self-Review + +- Spec coverage: DevTask starts as `todo`, TestCase starts as `pending`, actual timestamps are only written by single-item workflow transitions, TestCase estimates and weighted progress are added, Bug status flow is centralized, AI estimates are stricter and clamped on adoption. +- Placeholder scan: passed. Each task includes concrete files, code, commands, and expected results. +- Type consistency: `estimateHours` is added to DevTask, TestCase, and `AgentTestCaseDraft`; helper names used by stores and UI match the definitions in earlier tasks. diff --git a/packages/shared/package.json b/packages/shared/package.json index eda3a0d..72436f0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,9 +2,11 @@ "name": "@ftb/shared", "version": "0.0.1", "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", "type-check": "tsc --noEmit", "lint": "eslint src/" }, diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts new file mode 100644 index 0000000..da06ebd --- /dev/null +++ b/packages/shared/src/agent.ts @@ -0,0 +1,167 @@ +export type AgentRole = 'frontend' | 'backend' | 'ui' | 'testing'; + +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 type AgentReferenceType = 'requirement' | 'prototype_note'; + +export interface AgentReference { + type: AgentReferenceType; + id: string; + label: string; +} + +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'; + estimateHours: number; + references: AgentReference[]; +} + +export interface AgentDecomposeReport { + matched: Array<{ reqId: string; noteIds: string[]; taskCount: number }>; + reqOnly: string[]; + noteOnly: string[]; + ambiguous: Array<{ noteId: string; reason: string }>; +} + +export interface AgentDecomposeResult { + report: AgentDecomposeReport; + devTaskDrafts: AgentDevTaskDraft[]; + testCaseDrafts: AgentTestCaseDraft[]; +} + +export interface AgentDecomposeRequirement { + id: string; + code: string; + title: string; + description?: string; +} + +export interface AgentDecomposeMember { + name: string; + role: string; +} + +/** + * 前端发往后端的请求体 + */ +export interface AgentDecomposeRequest { + prototypeUrl: string; + requirements: AgentDecomposeRequirement[]; + members: AgentDecomposeMember[]; + versionId: string; + planId: string; +} + +/** + * 后端响应体(成功) + */ +export interface AgentDecomposeResponse { + ok: true; + result: AgentDecomposeResult; + meta: { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + }; +} + +/** + * 后端响应体(失败) + */ +export interface AgentDecomposeError { + ok: false; + error: string; + code: 'PROTOTYPE_FETCH_FAILED' | 'EMPTY_REQUIREMENTS' | 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; +} + +/* ───────────────────────── AI Provider 配置 ─────────────────────────── */ + +export type AiProviderFormat = 'anthropic' | 'openai'; + +/** + * 单个提供商配置(写入侧,含完整 apiKey) + */ +export interface AiProviderConfig { + id: string; // 用户给的唯一 id(如 'anthropic-official' / 'ikuncode') + name: string; // 显示名("Anthropic 官方" / "ikuncode") + format: AiProviderFormat; + baseURL: string; // 'https://api.anthropic.com' 或中转站 URL + apiKey: string; // 完整 Key(仅服务端持有) + model: string; // 该提供商上使用的模型 ID + remark?: string; + createdAt: string; +} + +/** + * 提供商公开视图(前端只能拿到这个,apiKey 已脱敏) + */ +export interface AiProviderPublic { + id: string; + name: string; + format: AiProviderFormat; + baseURL: string; + keyMask: string; // 脱敏后的 key(如 sk-ant...xxxx) + model: string; + remark?: string; + createdAt: string; + isActive: boolean; +} + +/** + * AI 配置整体(写入侧) + */ +export interface AiConfigStored { + providers: AiProviderConfig[]; + activeProviderId?: string; + updatedAt?: string; + updatedBy?: string; +} + +/** + * AI 配置公开视图(前端用) + */ +export interface AiConfigPublic { + providers: AiProviderPublic[]; + activeProviderId?: string; + updatedAt?: string; + updatedBy?: string; +} + +/** + * 创建 / 更新 提供商 入参 + */ +export interface AiProviderUpsertInput { + id: string; + name: string; + format: AiProviderFormat; + baseURL: string; + apiKey?: string; // 编辑时若不传则保留原 key + model: string; + remark?: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 968a286..b57aca8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './enums'; export * from './types'; +export * from './agent'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index ceabbe9..0d8e77a 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler", + "module": "CommonJS", + "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecb280f..237de02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/server: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.27.0 + version: 0.27.3 '@ftb/shared': specifier: workspace:* version: link:../../packages/shared @@ -41,6 +44,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + openai: + specifier: ^4.104.0 + version: 4.104.0 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -161,6 +167,9 @@ packages: resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@anthropic-ai/sdk@0.27.3': + resolution: {integrity: sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -774,6 +783,12 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.42': resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} @@ -866,6 +881,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -879,6 +898,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -962,6 +985,9 @@ packages: array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1164,6 +1190,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1275,6 +1305,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1350,6 +1384,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1390,6 +1428,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1479,6 +1521,17 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1578,6 +1631,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1593,6 +1650,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2071,6 +2131,11 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -2121,6 +2186,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -2785,6 +2862,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2842,6 +2922,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -2971,6 +3055,18 @@ snapshots: transitivePeerDependencies: - chokidar + '@anthropic-ai/sdk@0.27.3': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3725,6 +3821,15 @@ snapshots: '@types/mime@1.3.5': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 20.19.42 + form-data: 4.0.6 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@20.19.42': dependencies: undici-types: 6.21.0 @@ -3849,6 +3954,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -3860,6 +3969,10 @@ snapshots: acorn@8.16.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -3939,6 +4052,8 @@ snapshots: array-timsort@1.0.3: {} + asynckit@0.4.0: {} + autoprefixer@10.5.0(postcss@8.5.15): dependencies: browserslist: 4.28.2 @@ -4176,6 +4291,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@2.20.3: {} commander@4.1.1: {} @@ -4276,6 +4395,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + delayed-stream@1.0.0: {} + depd@2.0.0: {} destroy@1.2.0: {} @@ -4329,6 +4450,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -4354,6 +4482,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + events@3.3.0: {} execa@5.1.1: @@ -4506,6 +4636,21 @@ snapshots: typescript: 5.7.2 webpack: 5.97.1 + form-data-encoder@1.7.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -4604,6 +4749,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4620,6 +4769,10 @@ snapshots: human-signals@2.1.0: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5269,6 +5422,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.18.1 @@ -5305,6 +5460,18 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@4.104.0: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + ora@5.4.1: dependencies: bl: 4.1.0 @@ -5920,6 +6087,8 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} universalify@2.0.1: {} @@ -5969,6 +6138,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} webpack-node-externals@3.0.0: {}