feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ coverage/
.DS_Store .DS_Store
next-env.d.ts next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
apps/server/data/

View File

@@ -2,6 +2,23 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 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 ## Project Overview
FTB 智能项目管理系统 — 一个集成 AI 能力的项目管理平台,核心层级:产品 → 项目 → 迭代 → 任务。 FTB 智能项目管理系统 — 一个集成 AI 能力的项目管理平台,核心层级:产品 → 项目 → 迭代 → 任务。

View File

@@ -1,3 +1,6 @@
# 后端 NestJS 环境变量 — 复制为 .env 后填入真实值
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm
# Anthropic API KeyV3.1 起 AI 拆解 Agent 必需)
# 申请https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-xxx ANTHROPIC_API_KEY=sk-ant-xxx
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379

View File

@@ -0,0 +1,9 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
testEnvironment: 'node',
};

View File

@@ -16,6 +16,7 @@
"db:studio": "prisma studio" "db:studio": "prisma studio"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.0",
"@ftb/shared": "workspace:*", "@ftb/shared": "workspace:*",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@@ -24,6 +25,7 @@
"@prisma/client": "^5.15.0", "@prisma/client": "^5.15.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"openai": "^4.104.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0" "rxjs": "^7.8.0"
}, },

View File

@@ -167,3 +167,12 @@ model ProjectMember {
@@unique([projectId, userId]) @@unique([projectId, userId])
@@map("project_members") @@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")
}

View File

@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { ProductModule } from './modules/product/product.module'; import { ProductModule } from './modules/product/product.module';
import { RequirementModule } from './modules/requirement/requirement.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({ @Module({
imports: [PrismaModule, ProductModule, RequirementModule], imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
controllers: [], controllers: [],
providers: [], providers: [],
}) })

View File

@@ -1,7 +1,18 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
// 加载 .envNode 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() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');

View File

@@ -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<AiProvider> {
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<string> {
const cfg = await this.config.getActiveProvider();
if (!cfg) throw new Error('未配置任何 AI 提供商');
return cfg.model;
}
}

View File

@@ -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<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
}

View File

@@ -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 {}

View File

@@ -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_usecontent=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 () => '<html><body>QY0001手机号登录</body></html>',
});
(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');
});
});

View File

@@ -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<AgentDecomposeResponse | AgentDecomposeError> {
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<string> {
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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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<void> {
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 返回结果。`;
}
}

View File

@@ -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[];
}

View File

@@ -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'],
};

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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,
},
}),
);
});
});

View File

@@ -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<ToolCallResponse> {
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<string, any>,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
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('');
}
}

View File

@@ -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}`);
}

View File

@@ -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<ToolCallResponse> {
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<string, any>;
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<PingResponse> {
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,
};
}
}

View File

@@ -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;
/** 必须强制走 toolAnthropic: tool_choice / OpenAI: tool_choice */
forceTool: boolean;
maxTokens: number;
model: string;
}
export interface ToolCallResponse {
toolName: string;
toolInput: Record<string, any>;
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<ToolCallResponse>;
/** 极简调用,验证 Key + 端点可用 */
ping(req: PingRequest): Promise<PingResponse>;
}

View File

@@ -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 || '调用失败',
};
}
}
}

View File

@@ -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<AiProviderConfig | null> {
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<AiConfigPublic> {
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<AiConfigPublic> {
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<AiConfigPublic> {
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<AiConfigPublic> {
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<AiProviderConfig | null> {
const cfg = await this.read();
return cfg.providers.find((p) => p.id === id) ?? null;
}
private async read(): Promise<AiConfigStored> {
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<void> {
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)}`;
}
}

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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,
});
});
});

View File

@@ -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}`);
}
}
}

View File

@@ -1,13 +1,26 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
private connected = false;
async onModuleInit() { async onModuleInit() {
try {
await this.$connect(); 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() { async onModuleDestroy() {
if (this.connected) {
await this.$disconnect(); await this.$disconnect();
} }
} }
}

View File

@@ -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<AiProviderFormat, string> = {
anthropic: 'Anthropic 兼容',
openai: 'OpenAI 兼容',
};
const FORMAT_HINT: Record<AiProviderFormat, string> = {
anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站',
openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站',
};
const PRESETS: Array<{ key: string; label: string; data: Partial<AiProviderUpsertInput> & { 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 (
<SuperAdminGuard>
<AiConfigContent />
</SuperAdminGuard>
);
}
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 (
<div className="flex h-full items-center justify-center">
<p className="text-[14px] text-[var(--ink-soft)]">访 AI </p>
</div>
);
}
return <>{children}</>;
}
function AiConfigContent() {
const user = useAuthStore((s) => s.user);
const [config, setConfig] = useState<AiConfigPublic | null>(null);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<AiProviderPublic | null>(null);
const [testing, setTesting] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const fetchConfig = async () => {
setLoading(true);
try {
const c = await api.get<AiConfigPublic>('/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<AiConfigPublic>('/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<AiConfigPublic>(`/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 (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<Sparkles className="h-4 w-4 text-purple-600" />
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">AI </h1>
</header>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-4">
{loading ? (
<div className="text-[13px] text-[var(--ink-muted)]"></div>
) : (
<>
{/* 当前激活 */}
<section className="rounded-2xl border border-purple-200 bg-purple-50/50 p-5">
<h2 className="text-[12px] font-semibold text-purple-700 uppercase tracking-wide mb-2"></h2>
{activeProvider ? (
<div>
<div className="text-[15px] font-semibold text-[var(--ink)]">{activeProvider.name}</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)]">
{FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL}
</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)] font-mono">
{activeProvider.keyMask} · {activeProvider.model}
</div>
</div>
) : (
<div className="text-[13px] text-amber-700">AI </div>
)}
</section>
{/* 操作消息 */}
{actionMessage && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-[12px] text-blue-700 flex items-center justify-between">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="text-blue-500 hover:text-blue-700">
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 提供商列表 */}
<section className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[14px] font-semibold text-[var(--ink)]"></h2>
<button
onClick={() => { setEditingProvider(null); setShowModal(true); }}
className="flex items-center gap-1 h-8 px-3 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{config?.providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] py-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]">"新增"</p>
</div>
) : (
<div className="space-y-2">
{config?.providers.map((p) => (
<ProviderRow
key={p.id}
provider={p}
testing={testing === p.id}
testResult={testResults[p.id]}
onActivate={() => handleActivate(p.id)}
onEdit={() => { setEditingProvider(p); setShowModal(true); }}
onDelete={() => handleDelete(p.id, p.name)}
onTest={() => handleTest(p.id)}
/>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
{showModal && (
<ProviderModal
provider={editingProvider}
onClose={() => setShowModal(false)}
onSaved={(next) => {
setConfig(next);
setShowModal(false);
setActionMessage('保存成功');
}}
operator={user?.name}
/>
)}
</div>
);
}
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 (
<div className={`rounded-lg border p-3 ${provider.isActive ? 'border-purple-300 bg-purple-50/30' : 'border-[var(--line)]'}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-[var(--ink)]">{provider.name}</span>
{provider.isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-600 text-white font-medium"></span>
)}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{FORMAT_LABEL[provider.format]}
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--ink-soft)] truncate">{provider.baseURL}</div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)] font-mono">
{provider.keyMask} · {provider.model}
</div>
{provider.remark && <div className="mt-1 text-[11px] text-[var(--ink-muted)]">{provider.remark}</div>}
{testResult && (
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border ${
testResult.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-rose-50 border-rose-200 text-rose-700'
}`}>
{testResult.ok ? <CheckCircle2 className="h-3 w-3" /> : <AlertTriangle className="h-3 w-3" />}
{testResult.message}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 shrink-0">
{!provider.isActive && (
<button
onClick={onActivate}
className="h-7 px-3 rounded text-[11px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
</button>
)}
<button
onClick={onTest}
disabled={testing}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1 disabled:opacity-50"
>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</button>
<button
onClick={onEdit}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={onDelete}
className="h-7 px-3 rounded text-[11px] font-medium border border-rose-200 text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center gap-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
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<AiProviderFormat>(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<string | null>(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<AiConfigPublic>('/config/ai/providers', payload);
onSaved(next);
} catch (e: any) {
setError(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]">{isEdit ? '编辑提供商' : '新增提供商'}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
{!isEdit && (
<div className="mb-4">
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1.5"></label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.key}
onClick={() => applyPreset(p.key)}
className="h-7 px-2.5 rounded-md border border-[var(--line)] text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{p.label}
</button>
))}
</div>
</div>
)}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">ID *</label>
<input
value={id}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={name}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">API *</label>
<div className="grid grid-cols-2 gap-2">
{(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => (
<label
key={f}
className={`p-3 rounded-lg border cursor-pointer ${
format === f ? 'border-purple-400 bg-purple-50' : 'border-[var(--line)] hover:bg-[var(--bg-subtle)]'
}`}
>
<input type="radio" name="format" checked={format === f} onChange={() => setFormat(f)} className="mr-2" />
<span className="text-[13px] font-medium text-[var(--ink)]">{FORMAT_LABEL[f]}</span>
<p className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{FORMAT_HINT[f]}</p>
</label>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">Base URL *</label>
<input
value={baseURL}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">
API Key {isEdit ? '(留空则保留原 Key' : '*'}
</label>
<input
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => 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"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">Key </p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={model}
onChange={(e) => 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"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">
baseURL ID claude-sonnet-4-6claude-opus-4-7gpt-4odeepseek-v3
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"></label>
<input
value={remark}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-[12px] text-rose-700">
{error}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<button onClick={onClose} className="h-9 px-3 rounded-lg text-[13px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button
onClick={handleSave}
disabled={!canSubmit || saving}
className="h-9 px-4 rounded-lg text-[13px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore'; 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -15,7 +15,7 @@ export default function LoginPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@@ -25,21 +25,21 @@ export default function LoginPage() {
} }
setLoading(true); setLoading(true);
setTimeout(() => { try {
const success = login(phone.trim(), password, remember); const success = await login(phone.trim(), password, remember);
if (success) { if (success) {
router.push('/products'); router.push('/products');
} else { } else {
setError('手机号或密码错误'); setError('手机号或密码错误');
} }
} finally {
setLoading(false); setLoading(false);
}, 300); }
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]"> <div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center"> <div className="mb-8 flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent)] shadow-lg shadow-blue-500/20"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent)] shadow-lg shadow-blue-500/20">
<LayoutGrid className="h-6 w-6 text-white" strokeWidth={2} /> <LayoutGrid className="h-6 w-6 text-white" strokeWidth={2} />
@@ -48,47 +48,46 @@ export default function LoginPage() {
<p className="mt-1 text-[13px] text-[var(--ink-muted)]">使</p> <p className="mt-1 text-[13px] text-[var(--ink-muted)]">使</p>
</div> </div>
{/* Form */}
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]"> <div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label> <label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="relative"> <div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" /> <Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
<input <input
type="tel" type="tel"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号" placeholder="请输入手机号"
maxLength={11} 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)]"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label> <label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" /> <Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码" 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)]"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)] hover:text-[var(--ink-soft)]" className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)] hover:text-[var(--ink-soft)]"
aria-label={showPassword ? '隐藏密码' : '显示密码'}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center"> <label className="flex cursor-pointer items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={remember} checked={remember}
@@ -97,10 +96,9 @@ export default function LoginPage() {
/> />
<span className="text-[12px] text-[var(--ink-soft)]"></span> <span className="text-[12px] text-[var(--ink-soft)]"></span>
</label> </label>
</div>
{error && ( {error && (
<div className="rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-[12px] text-red-600"> <div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[12px] text-red-600">
{error} {error}
</div> </div>
)} )}
@@ -108,7 +106,7 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="h-10 w-full rounded-lg bg-[var(--accent)] text-[14px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-all" className="h-10 w-full rounded-lg bg-[var(--accent)] text-[14px] font-medium text-white shadow-[var(--shadow-sm)] transition-all hover:bg-[var(--accent-hover)] disabled:opacity-50"
> >
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>

View File

@@ -10,6 +10,8 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive'; import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage'; import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; 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 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(() => { const sortedVersions = useMemo(() => {
if (!project) return []; if (!project) return [];
let list = [...project.versions]; 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 !== 'all') {
if (statusFilter === 'planned') { if (statusFilter === 'planned') {
list = list.filter((v) => v.status === '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()); 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 // Compute actual overall progress per version
const versionProgressMap = useMemo(() => { const versionProgressMap = useMemo(() => {

View File

@@ -33,6 +33,7 @@ import { testCaseIntervals } from '@/lib/test-case';
import { bugIntervals } from '@/lib/bug'; import { bugIntervals } from '@/lib/bug';
import { planIntervals } from '@/lib/version-plan'; import { planIntervals } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format'; import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
const PRIORITY_STYLE: Record<string, string> = { const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600', P0: 'bg-red-500/10 text-red-600',
@@ -145,9 +146,10 @@ export default function VersionDetailPage() {
); );
} }
// 权限校验:只有参与人员可以访问 // 权限校验:只有参与人员可以访问(超管不受限)
const currentUserName = user?.name || ''; 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) { if (!isMember) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
@@ -512,7 +514,7 @@ export default function VersionDetailPage() {
{g.items.map((p) => ( {g.items.map((p) => (
<a key={p.id} href={p.resultUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 text-[12px] text-[var(--accent)] hover:underline"> <a key={p.id} href={p.resultUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 text-[12px] text-[var(--accent)] hover:underline">
{p.resultType === 'file' ? <FileText className="h-3 w-3" /> : <Link2 className="h-3 w-3" />} {p.resultType === 'file' ? <FileText className="h-3 w-3" /> : <Link2 className="h-3 w-3" />}
<span className="truncate">{p.resultFileName || p.title}</span> <span className="truncate">{p.resultTitle || p.resultFileName || p.title}</span>
</a> </a>
))} ))}
</div> </div>
@@ -887,17 +889,18 @@ export default function VersionDetailPage() {
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? ( ) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => { (() => {
const pt = activeTab as 'research' | 'product' | 'ui'; const pt = activeTab as 'research' | 'product' | 'ui';
const versionReqs = requirements.filter((r) => r.versionId === version.id); const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
return ( return (
<PlanTab <PlanTab
plans={plans} plans={plans}
versionId={version.id} versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined} versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''} currentUserName={user?.name ?? ''}
planType={pt} planType={pt}
versionMembers={version.members ?? []} versionMembers={version.members ?? []}
linkedRequirements={pt !== 'research' ? linkedReqs : undefined} linkedRequirements={projectAdoptedReqs}
allRequirements={requirements}
onCreate={(data) => { onCreate={(data) => {
createPlan(data); createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) { if ((pt === 'product') && data.linkedRequirementIds?.length) {

View File

@@ -10,6 +10,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { flattenVersions, flattenProjects } from '@/lib/derive'; import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } 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'; 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 user = useAuthStore((s) => s.user);
const currentUserName = user?.name || ''; 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]); const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]);
// 只显示当前用户参与的版本members为空时所有人可见 // 只显示当前用户参与的版本members为空时所有人可见;超管可见全部
const allVersions = useMemo(() => allVersionsRaw.filter((v) => { const allVersions = useMemo(() => allVersionsRaw.filter((v) => {
if (isSuperAdmin) return true;
if (!v.members || v.members.length === 0) return true; if (!v.members || v.members.length === 0) return true;
return v.members.some((m) => m.name === currentUserName); return v.members.some((m) => m.name === currentUserName);
}), [allVersionsRaw, currentUserName]); }), [allVersionsRaw, currentUserName, isSuperAdmin]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allProjects = useMemo(() => flattenProjects(overview), [overview]);
// Compute overall progress per version from actual data // Compute overall progress per version from actual data

View File

@@ -51,12 +51,17 @@ export function DevTaskRow({ task, category, onClick }: Props) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0" className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${task.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}
> >
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[task.priority] || 'bg-zinc-300'}`} title={task.priority} /> <span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[task.priority] || 'bg-zinc-300'}`} title={task.priority} />
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{task.taskNo}</span> <span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{task.taskNo}</span>
<div className="flex-1 min-w-0 flex items-center gap-1.5"> <div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[13px] text-[var(--ink)] truncate">{task.title}</span> <span className="text-[13px] text-[var(--ink)] truncate">{task.title}</span>
{task.aiDraft && (
<span className="flex items-center gap-0.5 text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记">
AI
</span>
)}
{task.isBlocked && ( {task.isBlocked && (
<span className="flex items-center gap-0.5 text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0" title={task.blockReason}> <span className="flex items-center gap-0.5 text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0" title={task.blockReason}>
<AlertTriangle className="h-2.5 w-2.5" /> <AlertTriangle className="h-2.5 w-2.5" />

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { usePathname, useRouter } from 'next/navigation'; 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 { useHasPermission } from '@/components/auth/Guard';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
@@ -23,6 +23,7 @@ const NAV_GROUPS = [
items: [ items: [
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' }, { label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' }, { label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
], ],
}, },
]; ];

View File

@@ -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<AgentDecomposeResponse | null>(null);
const [tick, setTick] = useState(0);
const startedAtRef = useRef<number | null>(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<AgentDecomposeResponse | AgentDecomposeError>(
'/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 (
<>
<button
onClick={handleClick}
disabled={isInProgress}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
isError
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
}`}
title={
isError
? `上次失败:${persistError || '未知错误'}(点击重试)`
: wasCompleted
? '此前已拆解过,再次点击会重新拆解'
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
}
>
{isInProgress ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{formatElapsed(elapsedSec)}
</>
) : isError ? (
<>
<RotateCw className="h-3 w-3" />
</>
) : (
<>
<Sparkles className="h-3 w-3" />
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
</>
)}
</button>
{/* 错误信息:在按钮旁悬浮显示 */}
{isError && persistError && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-red-600 max-w-[260px]" title={persistError}>
<AlertCircle className="h-3 w-3 shrink-0" />
<span className="truncate">{persistError}</span>
</span>
)}
{/* 卡死提示persistStatus 是 in_progress 但已超时 */}
{isStaleInProgress && (
<span className="ml-1 text-[11px] text-amber-600" title="上次拆解记录残留,点击按钮重新拆解">
{formatElapsed(elapsedSec)}
</span>
)}
{result && (
<DecomposeReportModal
result={result}
version={version}
plan={plan}
requirements={linkedReqs}
onClose={() => setResult(null)}
/>
)}
</>
);
}

View File

@@ -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<Set<number>>(
new Set(data.devTaskDrafts.map((_, i) => i)),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(data.testCaseDrafts.map((_, i) => i)),
);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const reqByCode = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
for (const r of requirements) m.set(r.code, r);
return m;
}, [requirements]);
const reqByInternalId = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
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 类型 idAI 给的是 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-purple-600" />
<h3 className="text-[14px] font-semibold text-[var(--ink)]">AI </h3>
<span className="text-[11px] text-[var(--ink-muted)]">
{meta.model} · {meta.inputTokens} tok · {meta.outputTokens} tok ·{' '}
{(meta.durationMs / 1000).toFixed(1)}s
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]">
<X className="h-4 w-4 text-[var(--ink-muted)]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* 对账报告 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2"></h4>
{data.report.matched.length > 0 && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
<span className="text-[12px] font-medium text-emerald-700">
{data.report.matched.length}
</span>
</div>
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
{data.report.matched.map((m, i) => (
<li key={i}>
{m.reqId} {m.noteIds.join(', ') || '(仅需求驱动)'} {m.taskCount}
</li>
))}
</ul>
</div>
)}
{data.report.reqOnly.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
<span className="text-[12px] font-medium text-amber-700"></span>
</div>
<p className="text-[11px] text-amber-700 mb-1">
</p>
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
{data.report.reqOnly.map((id, i) => <li key={i}>{id}</li>)}
</ul>
</div>
)}
{data.report.noteOnly.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
<span className="text-[12px] font-medium text-blue-700"></span>
</div>
<p className="text-[11px] text-blue-700 mb-1">
</p>
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</li>)}
</ul>
</div>
)}
{data.report.ambiguous.length > 0 && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<HelpCircle className="h-3.5 w-3.5 text-rose-600" />
<span className="text-[12px] font-medium text-rose-700"></span>
</div>
<ul className="text-[12px] text-rose-800 ml-5 space-y-0.5">
{data.report.ambiguous.map((a, i) => (
<li key={i}>
<span className="font-medium">{a.noteId}</span>{a.reason}
</li>
))}
</ul>
</div>
)}
</section>
{/* DevTask 草案 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.devTaskDrafts.length}
</h4>
{data.devTaskDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.devTaskDrafts.map((d, i) => (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedDevIdx.has(i)}
onChange={() => toggleDev(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
</span>
</div>
{d.description && (
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
))}
</div>
)}
</section>
{/* TestCase 草案 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.testCaseDrafts.length}
</h4>
{data.testCaseDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.testCaseDrafts.map((d, i) => (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedTcIdx.has(i)}
onChange={() => toggleTc(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
</span>
</div>
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
{d.description}
</pre>
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
))}
</div>
)}
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
<div className="text-[12px] text-[var(--ink-muted)]">
{selectedDevIdx.size} + {selectedTcIdx.size}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
onClick={handleAdopt}
disabled={submitting || submitted || (selectedDevIdx.size === 0 && selectedTcIdx.size === 0)}
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format'; import { formatDateTime } from '@/lib/format';
import type { PlanTask, VersionPlan } from '@/lib/version-plan'; import type { PlanTask, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
interface Props { interface Props {
planId: string; planId: string;
@@ -26,6 +27,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const [showTransfer, setShowTransfer] = useState(false); const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState(''); const [transferTo, setTransferTo] = useState('');
const [resultType, setResultType] = useState<'link' | 'file'>('link'); const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [resultUrl, setResultUrl] = useState(''); const [resultUrl, setResultUrl] = useState('');
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState(''); const [fileData, setFileData] = useState('');
@@ -34,20 +36,22 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const plan = plans.find((p) => p.id === planId); const plan = plans.find((p) => p.id === planId);
if (!plan) return null; if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const isResearch = plan.type === 'research'; const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds); 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 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) => { const handleToggleTask = (task: PlanTask) => {
if (!canInteract) return; if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed'; const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t); const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks }); updatePlan(plan.id, { tasks: updatedTasks });
}; };
const handleToggleReq = (reqId: string) => { const handleToggleReq = (reqId: string) => {
if (!canInteract) return; if (!canEditCoverage) return;
const current = plan.completedRequirementIds || []; const current = plan.completedRequirementIds || [];
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId]; const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
updatePlan(plan.id, { completedRequirementIds: next }); updatePlan(plan.id, { completedRequirementIds: next });
@@ -64,8 +68,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const handleSubmitResult = () => { const handleSubmitResult = () => {
const url = resultType === 'link' ? resultUrl.trim() : fileData; const url = resultType === 'link' ? resultUrl.trim() : fileData;
if (!url) return; const title = resultTitle.trim();
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined }); 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); setShowComplete(false);
}; };
@@ -138,9 +147,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{plan.tasks.map((task) => ( {plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"> <div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button <button
disabled={!canInteract} disabled={!canToggle}
onClick={() => handleToggleTask(task)} onClick={() => handleToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
> >
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />} {task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button> </button>
@@ -151,7 +160,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)} )}
{/* Product/UI: Linked Requirements */} {/* Product/UI: Linked Requirements */}
{!isResearch && linkedReqs.length > 0 && ( {linkedReqs.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div> <div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{linkedReqs.map((req) => { {linkedReqs.map((req) => {
@@ -159,9 +168,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
return ( return (
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"> <div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button <button
disabled={!canInteract} disabled={!canEditCoverage}
onClick={() => handleToggleReq(req.id)} onClick={() => handleToggleReq(req.id)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
> >
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />} {isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button> </button>
@@ -170,6 +179,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div> </div>
); );
})} })}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div> </div>
)} )}
@@ -180,7 +192,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />} {plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1"> <a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" /> {plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a> </a>
</div> </div>
</div> </div>
@@ -212,6 +224,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{showComplete && ( {showComplete && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2"> <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700"></div> <div className="text-[11px] font-medium text-emerald-700"></div>
<input value={resultTitle} onChange={(e) => 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" />
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button> <button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button> <button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
@@ -225,7 +238,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div> </div>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={handleSubmitResult} disabled={resultType === 'link' ? !resultUrl.trim() : !fileData} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"></button> <button onClick={handleSubmitResult} disabled={!completionState.canSubmitResult || !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData)} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button> <button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div> </div>
</div> </div>
@@ -234,6 +247,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{/* Footer Actions */} {/* Footer Actions */}
{plan.status !== 'completed' && ( {plan.status !== 'completed' && (
<>
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0"> <div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && ( {plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1"> <button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
@@ -241,7 +255,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</button> </button>
)} )}
{plan.status === 'in_progress' && ( {plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50"> <button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button> </button>
)} )}
@@ -249,6 +263,10 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
<ArrowRightLeft className="h-3 w-3" /> <ArrowRightLeft className="h-3 w-3" />
</button> </button>
</div> </div>
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</div>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,23 +1,30 @@
'use client'; '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 { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { VersionPlan, PlanTask } from '@/lib/version-plan'; import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format'; import { formatDateTime } from '@/lib/format';
import { FieldError } from '@/components/FieldError'; 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 { interface Props {
plans: VersionPlan[]; plans: VersionPlan[];
versionId: string; versionId: string;
version?: VersionWithContext;
versionDeadline?: string; versionDeadline?: string;
currentUserName: string; currentUserName: string;
planType: 'research' | 'product' | 'ui'; planType: 'research' | 'product' | 'ui';
versionMembers: { role: string; name: string }[]; versionMembers: { role: string; name: string }[];
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[]; linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void; onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void; onUpdate: (id: string, data: Partial<VersionPlan>) => 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; onDelete: (id: string) => void;
} }
@@ -29,7 +36,7 @@ const STATUS_STYLE = {
}; };
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' }; 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 [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null); const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null); const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(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 autoStarted = plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date();
const effectiveStatus = autoStarted ? 'in_progress' : plan.status; const effectiveStatus = autoStarted ? 'in_progress' : plan.status;
const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null); 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 const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
? calcPlanDuration(plan.actualStartAt, plan.completedAt) ? calcPlanDuration(plan.actualStartAt, plan.completedAt)
@@ -102,11 +113,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<div className="flex items-center gap-1.5 mt-2"> <div className="flex items-center gap-1.5 mt-2">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />} {plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1"> <a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" /> {plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a> </a>
{planType === 'product' && version && (
<AiDecomposeButton plan={plan} version={version} />
)}
</div> </div>
)} )}
{/* 调研:任务进度 */} {/* 子任务 */}
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && ( {plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -119,14 +133,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{plan.tasks.map((task) => ( {plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2"> <div key={task.id} className="flex items-center gap-2">
<button <button
disabled={plan.status !== 'in_progress' && !autoStarted} disabled={!canToggle}
onClick={() => { onClick={() => {
if (plan.status !== 'in_progress' && !autoStarted) return; if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed'; const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t); const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
onUpdate(plan.id, { tasks: updatedTasks }); onUpdate(plan.id, { tasks: updatedTasks });
}} }}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
> >
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />} {task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button> </button>
@@ -139,8 +153,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</div> </div>
</div> </div>
)} )}
{/* 产品方案/UI关联需求进度 */} {/* 关联需求 */}
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && ( {plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden"> <div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
@@ -150,19 +164,19 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{plan.linkedRequirementIds.map((rid) => { {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); const isDone = (plan.completedRequirementIds || []).includes(rid);
return req ? ( return req ? (
<div key={rid} className="flex items-center gap-2"> <div key={rid} className="flex items-center gap-2">
<button <button
disabled={plan.status !== 'in_progress' && !autoStarted} disabled={!canEditCoverage}
onClick={() => { onClick={() => {
if (plan.status !== 'in_progress' && !autoStarted) return; if (!canEditCoverage) return;
const current = plan.completedRequirementIds || []; const current = plan.completedRequirementIds || [];
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid]; const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
onUpdate(plan.id, { completedRequirementIds: next }); onUpdate(plan.id, { completedRequirementIds: next });
}} }}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
> >
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />} {isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button> </button>
@@ -172,13 +186,10 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
) : null; ) : null;
})} })}
</div> </div>
{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && plan.status !== 'completed' && (
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
<span className="text-[12px] text-green-700"></span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline"></button>
</div> </div>
)} )}
</div> {plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)} )}
</div> </div>
<div className="flex items-center gap-1 ml-3"> <div className="flex items-center gap-1 ml-3">
@@ -187,11 +198,6 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<Play className="h-3 w-3" /> <Play className="h-3 w-3" />
</button> </button>
)} )}
{plan.status !== 'completed' && plan.status !== 'pending' && (
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
<Check className="h-3.5 w-3.5" />
</button>
)}
{plan.status !== 'completed' && ( {plan.status !== 'completed' && (
<> <>
<button onClick={() => setTransferPlanId(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交"> <button onClick={() => setTransferPlanId(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
@@ -218,6 +224,12 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<button onClick={() => { setTransferPlanId(null); setTransferTo(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button> <button onClick={() => { setTransferPlanId(null); setTransferTo(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div> </div>
)} )}
{plan.status !== 'completed' && completionState.canSubmitResult && (
<div className="mt-3 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
<span className="text-[12px] text-green-700"></span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline"></button>
</div>
)}
</div> </div>
); );
})} })}
@@ -232,6 +244,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
versionDeadline={versionDeadline} versionDeadline={versionDeadline}
currentUserName={currentUserName} currentUserName={currentUserName}
linkedRequirements={linkedRequirements} linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }} onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
onSubmit={(data) => { onSubmit={(data) => {
if (editingPlan) onUpdate(editingPlan.id, data); if (editingPlan) onUpdate(editingPlan.id, data);
@@ -245,20 +258,28 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{completingPlan && ( {completingPlan && (
<CompleteModal <CompleteModal
onClose={() => setCompletingPlan(null)} onClose={() => 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);
}}
/> />
)} )}
</div> </div>
); );
} }
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: { function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
initial: VersionPlan | null; initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui'; planType: 'research' | 'product' | 'ui';
versionId: string; versionId: string;
versionDeadline?: string; versionDeadline?: string;
currentUserName: string; currentUserName: string;
linkedRequirements?: { id: string; title: string; code: string }[]; linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onClose: () => void; onClose: () => void;
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
}) { }) {
@@ -273,7 +294,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? ''); const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? [])); const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const [endTimeError, setEndTimeError] = useState(''); 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 isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -294,8 +318,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
startTime, startTime,
endTime, endTime,
status: initial?.status ?? 'pending', status: initial?.status ?? 'pending',
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined, linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
tasks: tasks.length > 0 ? tasks : undefined, tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
remark: remark.trim() || undefined, remark: remark.trim() || undefined,
overdueReason: isOverdue ? overdueReason.trim() : undefined, overdueReason: isOverdue ? overdueReason.trim() : undefined,
addedBy: currentUserName, addedBy: currentUserName,
@@ -345,15 +369,35 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
/> />
</div> </div>
)} )}
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && ( {requirementOptions.length > 0 && (
<div> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label> <label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] text-[var(--ink-muted)]"> {selectedReqs.size} / {requirementOptions.filter((req) => !req.isHistorical).length}</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedReqs(new Set(requirementOptions.map((req) => req.id)))}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
<button
type="button"
onClick={() => setSelectedReqs(new Set())}
className="text-[11px] text-[var(--ink-muted)] hover:underline"
>
</button>
</div>
</div>
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1"> <div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
{linkedRequirements.map((req) => ( {requirementOptions.map((req) => (
<label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]"> <label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]">
<input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" /> <input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" />
<span className="text-[var(--ink-muted)] font-mono">{req.code}</span> <span className="text-[var(--ink-muted)] font-mono">{req.code}</span>
<span className="text-[var(--ink)] truncate">{req.title}</span> <span className="text-[var(--ink)] truncate">{req.title}</span>
{req.isHistorical && <span className="text-[10px] text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded"></span>}
</label> </label>
))} ))}
</div> </div>
@@ -362,7 +406,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
{planType === 'research' && ( {planType === 'research' && (
<div> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"> <label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
<span className="text-[10px] text-[var(--ink-muted)] ml-1"></span> <span className="text-[10px] text-[var(--ink-muted)] ml-1"></span>
</label> </label>
{/* 预设选项 */} {/* 预设选项 */}
@@ -393,13 +437,13 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
value={newTaskTitle} value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)} onChange={(e) => 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(''); } } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
placeholder="自定义任务名称,回车添加" 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" 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"
/> />
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]"></button> <button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]"></button>
</div> </div>
{tasks.length === 0 && ( {tasks.length === 0 && (
<div className="text-[11px] text-red-500 mt-1"></div> <div className="text-[11px] text-red-500 mt-1"></div>
)} )}
</div> </div>
)} )}
@@ -419,9 +463,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
function CompleteModal({ onClose, onSubmit }: { function CompleteModal({ onClose, onSubmit }: {
onClose: () => void; 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 [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState(''); const [fileData, setFileData] = useState('');
@@ -435,7 +480,7 @@ function CompleteModal({ onClose, onSubmit }: {
reader.readAsDataURL(file); 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
@@ -445,6 +490,10 @@ function CompleteModal({ onClose, onSubmit }: {
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button> <button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<input value={resultTitle} onChange={(e) => 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" />
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button> <button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button> <button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
@@ -459,7 +508,7 @@ function CompleteModal({ onClose, onSubmit }: {
)} )}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button> <button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button> <button onClick={() => onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import type { Requirement, ChangeReason } from '@/lib/requirement';
import type { DevTask } from '@/lib/dev-task'; import type { DevTask } from '@/lib/dev-task';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement'; 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 { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
interface Props { interface Props {
versionId: string; versionId: string;
@@ -23,7 +24,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [showChangeModal, setShowChangeModal] = useState(false); const [showChangeModal, setShowChangeModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -43,7 +44,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
{linkedReqs.length === 0 ? ( {linkedReqs.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
@@ -213,7 +214,7 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}> <div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]"> <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3> <h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button> <button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div> </div>
<div className="px-5 py-3 border-b border-[var(--line)]"> <div className="px-5 py-3 border-b border-[var(--line)]">
@@ -221,6 +222,15 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
<input value={search} onChange={(e) => 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" /> <input value={search} onChange={(e) => 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" />
</div> </div>
{filtered.length > 0 && (
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-[var(--ink-muted)]"> {filtered.length} {selected.size} </span>
<div className="flex gap-2">
<button type="button" onClick={() => setSelected(new Set(filtered.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline"></button>
<button type="button" onClick={() => setSelected(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline"></button>
</div>
</div>
)}
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-3"> <div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? ( {filtered.length === 0 ? (

View File

@@ -8,7 +8,8 @@ async function checkApi(): Promise<boolean> {
if (probePromise) return probePromise; if (probePromise) return probePromise;
probePromise = (async () => { probePromise = (async () => {
try { 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; apiAvailable = res.ok;
} catch { } catch {
apiAvailable = false; apiAvailable = false;
@@ -37,7 +38,29 @@ export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) => post: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(data) }), request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) => patch: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }), request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }), delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
// 调用 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);
}
},
}; };

View File

@@ -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);
});

View File

@@ -0,0 +1,3 @@
export function shouldPersistRemoteOverview(overview: unknown): boolean {
return Array.isArray(overview) && overview.length > 0;
}

View File

@@ -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);
});

View File

@@ -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];
}

View File

@@ -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<T> {
key: ServerDataKey;
value: T | null;
}
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
return res.value;
}
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
await api.put(`/data/${key}`, { value });
}

View File

@@ -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, '后端接口');
});

View File

@@ -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 { export interface TaskCategory {
id: string; id: string;
code: string;
name: string; name: string;
group: CategoryGroup; group: CategoryGroup;
color?: string; color?: string;
@@ -11,19 +14,68 @@ export interface TaskCategory {
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = { export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
development: '开发', development: '开发',
testing: '测试',
implementation: '实施', implementation: '实施',
other: '其他', other: '其他',
}; };
export const PRESET_CATEGORIES: TaskCategory[] = [ export const PRESET_CATEGORIES: TaskCategory[] = [
{ id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true }, { id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
{ id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true }, { id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true },
{ id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true }, { id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true },
{ id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true }, { id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 4, isSystem: true },
{ id: 'cat-5', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 5, isSystem: true }, { id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
{ id: 'cat-6', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 6, 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<string, string> = {
'cat-1': 'frontend_development',
'cat-2': 'backend_development',
'cat-3': 'database_schema',
'cat-4': 'api_integration',
'cat-5': 'data_processing',
'cat-6': 'implementation_support',
};
function slugifyCategoryName(name: string, index: number): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
.replace(/^_+|_+$/g, '') || `category_${index + 1}`
);
}
export function normalizeTaskCategory(category: Partial<TaskCategory> | 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>[] = []): TaskCategory[] {
const merged: Partial<TaskCategory>[] = 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 { export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined {
return categories.find((c) => c.id === id); 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[] { export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder); 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;
}

View File

@@ -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>): 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);
});

View File

@@ -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);
}

View File

@@ -19,6 +19,7 @@ export interface VersionPlan {
completedRequirementIds?: string[]; completedRequirementIds?: string[];
linkedRequirementIds?: string[]; linkedRequirementIds?: string[];
resultType?: 'link' | 'file'; resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string; resultUrl?: string;
resultFileName?: string; resultFileName?: string;
resultFileData?: string; resultFileData?: string;
@@ -28,6 +29,10 @@ export interface VersionPlan {
createdAt: string; createdAt: string;
completedAt?: string; completedAt?: string;
addedBy: string; addedBy: string;
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
aiDecomposeAt?: string;
aiDecomposeBy?: string;
aiDecomposeError?: string;
} }
export type PlanType = VersionPlan['type']; export type PlanType = VersionPlan['type'];

View File

@@ -3,11 +3,12 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3000", "dev": "node --max-old-space-size=4096 node_modules/next/dist/bin/next dev --port 3000",
"build": "next build", "build": "node --max-old-space-size=4096 node_modules/next/dist/bin/next build",
"start": "next start", "start": "next start",
"lint": "next lint", "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": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -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);

View File

@@ -1,6 +1,9 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { loadServerData } from '@/lib/server-data';
import type { Member } from '@/lib/members';
import { useMemberStore } from './useMemberStore';
interface AuthUser { interface AuthUser {
id: string; id: string;
@@ -14,45 +17,44 @@ interface AuthUser {
interface AuthState { interface AuthState {
user: AuthUser | null; user: AuthUser | null;
isAuthenticated: boolean; isAuthenticated: boolean;
login: (phone: string, password: string, remember: boolean) => boolean; login: (phone: string, password: string, remember: boolean) => Promise<boolean>;
logout: () => void; logout: () => void;
checkAuth: () => void; checkAuth: () => void;
refreshUser: () => void; refreshUser: () => Promise<void>;
}
interface StoredMembersData {
members: Member[];
} }
const SESSION_KEY = 'ftb_auth_session'; const SESSION_KEY = 'ftb_auth_session';
const PERSIST_KEY = 'ftb_auth_persist'; const PERSIST_KEY = 'ftb_auth_persist';
export const useAuthStore = create<AuthState>((set) => ({ const FALLBACK_MEMBERS: Member[] = [
user: null, {
isAuthenticated: false, id: 'm-8',
name: '\u8d85\u7ea7\u7ba1\u7406\u5458',
login: (phone, password, remember) => { departmentId: 'dept-1',
const membersRaw = localStorage.getItem('ftb_members_v1'); roleId: 'role-admin',
let members: any[] = []; phone: '13200132008',
if (membersRaw) { email: 'chenshi@company.com',
const parsed = JSON.parse(membersRaw); password: 'Ftb@2024',
members = parsed.members || []; createdAt: '2024-01-01',
} },
// 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' },
]; ];
async function loadMembersForAuth(): Promise<Member[]> {
try {
const stored = await loadServerData<StoredMembersData>('members');
if (stored?.members?.length) return stored.members;
} catch {}
const inMemoryMembers = useMemberStore.getState().members;
return inMemoryMembers.length > 0 ? inMemoryMembers : FALLBACK_MEMBERS;
} }
const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password); function toAuthUser(member: Member): AuthUser {
if (!member) return false; return {
const user: AuthUser = {
id: member.id, id: member.id,
name: member.name, name: member.name,
roleId: member.roleId, roleId: member.roleId,
@@ -60,7 +62,18 @@ export const useAuthStore = create<AuthState>((set) => ({
phone: member.phone, phone: member.phone,
email: member.email, email: member.email,
}; };
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
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 = toAuthUser(member);
set({ user, isAuthenticated: true }); set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user)); sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
if (remember) { if (remember) {
@@ -90,16 +103,16 @@ export const useAuthStore = create<AuthState>((set) => ({
} }
}, },
refreshUser: () => { refreshUser: async () => {
const cur = useAuthStore.getState().user; const cur = useAuthStore.getState().user;
if (!cur) return; if (!cur) return;
try { try {
const raw = localStorage.getItem('ftb_members_v1'); const memoryMember = useMemberStore.getState().members.find((x) => x.id === cur.id);
if (!raw) return; const members = memoryMember ? [memoryMember] : await loadMembersForAuth();
const parsed = JSON.parse(raw); const member = members.find((x) => x.id === cur.id);
const m = parsed.members?.find((x: any) => x.id === cur.id); if (!member) return;
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 next = toAuthUser(member);
useAuthStore.setState({ user: next }); useAuthStore.setState({ user: next });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next)); sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
if (localStorage.getItem(PERSIST_KEY)) { if (localStorage.getItem(PERSIST_KEY)) {

View File

@@ -3,8 +3,7 @@ import { create } from 'zustand';
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members'; import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members'; import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions'; import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_members_v1';
const PRESET_DEPARTMENTS: Department[] = [ const PRESET_DEPARTMENTS: Department[] = [
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' }, { id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
@@ -25,14 +24,7 @@ const PRESET_ROLES: RoleItem[] = [
]; ];
const MOCK_MEMBERS: Member[] = [ 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-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
{ 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' },
]; ];
interface MemberState { interface MemberState {
@@ -54,14 +46,13 @@ interface MemberState {
deleteRole: (id: string) => void; deleteRole: (id: string) => void;
} }
function saveLocal(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) { function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} 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 { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
@@ -73,8 +64,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
passwordRule: DEFAULT_PASSWORD_RULE, passwordRule: DEFAULT_PASSWORD_RULE,
loading: false, loading: false,
fetchMembers: () => { fetchMembers: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) { if (cached) {
// 迁移:旧 RoleItem 没 permissions 字段,补默认值 // 迁移:旧 RoleItem 没 permissions 字段,补默认值
const migratedRoles = cached.roles.map((r) => { const migratedRoles = cached.roles.map((r) => {
@@ -89,41 +80,41 @@ export const useMemberStore = create<MemberState>((set, get) => ({
updatePasswordRule: (rule) => { updatePasswordRule: (rule) => {
set({ passwordRule: 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) => { createDepartment: (data) => {
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const departments = [...get().departments, dept]; const departments = [...get().departments, dept];
set({ departments }); 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) => { updateDepartment: (id, data) => {
const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d); const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d);
set({ departments }); 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) => { deleteDepartment: (id) => {
const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id); const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id);
set({ departments }); 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) => { createMember: (data) => {
const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const members = [...get().members, member]; const members = [...get().members, member];
set({ members }); 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) => { updateMember: (id, data) => {
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m); const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
set({ members }); 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) => { deleteMember: (id) => {
const members = get().members.filter((m) => m.id !== id); const members = get().members.filter((m) => m.id !== id);
set({ members }); 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) => { createRole: (data) => {
@@ -135,7 +126,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
}; };
const roles = [...get().roles, role]; const roles = [...get().roles, role];
set({ roles }); 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) => { updateRole: (id, data) => {
const roles = get().roles.map((r) => { const roles = get().roles.map((r) => {
@@ -148,11 +139,11 @@ export const useMemberStore = create<MemberState>((set, get) => ({
return { ...r, ...data }; return { ...r, ...data };
}); });
set({ roles }); 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) => { deleteRole: (id) => {
const roles = get().roles.filter((r) => r.id !== id); const roles = get().roles.filter((r) => r.id !== id);
set({ roles }); set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }); saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
}, },
})); }));

View File

@@ -3,8 +3,7 @@ import { create } from 'zustand';
import type { OvertimeRecord } from '@/lib/overtime'; import type { OvertimeRecord } from '@/lib/overtime';
import type { DictItem } from '@/lib/requirement'; import type { DictItem } from '@/lib/requirement';
import { calcDuration } from '@/lib/overtime'; import { calcDuration } from '@/lib/overtime';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_overtime_v1';
const PRESET_REASONS: DictItem[] = [ const PRESET_REASONS: DictItem[] = [
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' }, { id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
@@ -20,19 +19,13 @@ const PRESET_REASONS: DictItem[] = [
{ id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' }, { id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' },
]; ];
const MOCK_RECORDS: OvertimeRecord[] = [ 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' },
];
interface OvertimeState { interface OvertimeState {
records: OvertimeRecord[]; records: OvertimeRecord[];
reasons: DictItem[]; reasons: DictItem[];
loading: boolean; loading: boolean;
fetchRecords: () => void; fetchRecords: () => Promise<void>;
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void; createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void; updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
deleteRecord: (id: string) => void; deleteRecord: (id: string) => void;
@@ -41,14 +34,13 @@ interface OvertimeState {
deleteReason: (id: string) => void; deleteReason: (id: string) => void;
} }
function saveLocal(data: { records: OvertimeRecord[]; reasons: DictItem[] }) { function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {} saveServerData('overtime', data).catch(() => {});
} }
function loadLocal(): { records: OvertimeRecord[]; reasons: DictItem[] } | null { async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
@@ -58,8 +50,8 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
reasons: PRESET_REASONS, reasons: PRESET_REASONS,
loading: false, loading: false,
fetchRecords: () => { fetchRecords: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) set({ records: cached.records, reasons: cached.reasons }); if (cached) set({ records: cached.records, reasons: cached.reasons });
}, },
@@ -73,7 +65,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
}; };
const records = [...get().records, record]; const records = [...get().records, record];
set({ records }); set({ records });
saveLocal({ records, reasons: get().reasons }); saveStored({ records, reasons: get().reasons });
}, },
updateRecord: (id, data) => { updateRecord: (id, data) => {
@@ -86,31 +78,31 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
return updated; return updated;
}); });
set({ records }); set({ records });
saveLocal({ records, reasons: get().reasons }); saveStored({ records, reasons: get().reasons });
}, },
deleteRecord: (id) => { deleteRecord: (id) => {
const records = get().records.filter((r) => r.id !== id); const records = get().records.filter((r) => r.id !== id);
set({ records }); set({ records });
saveLocal({ records, reasons: get().reasons }); saveStored({ records, reasons: get().reasons });
}, },
addReason: (name) => { addReason: (name) => {
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const reasons = [...get().reasons, item]; const reasons = [...get().reasons, item];
set({ reasons }); set({ reasons });
saveLocal({ records: get().records, reasons }); saveStored({ records: get().records, reasons });
}, },
updateReason: (id, name) => { updateReason: (id, name) => {
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r); const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
set({ reasons }); set({ reasons });
saveLocal({ records: get().records, reasons }); saveStored({ records: get().records, reasons });
}, },
deleteReason: (id) => { deleteReason: (id) => {
const reasons = get().reasons.filter((r) => r.id !== id); const reasons = get().reasons.filter((r) => r.id !== id);
set({ reasons }); set({ reasons });
saveLocal({ records: get().records, reasons }); saveStored({ records: get().records, reasons });
}, },
})); }));

View File

@@ -3,6 +3,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { Product } from '@ftb/shared'; import { Product } from '@ftb/shared';
import { api } from '@/lib/api'; 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 { Stage, Role } from '@/lib/stage';
import type { Priority, VersionLinks } from '@/lib/derive'; import type { Priority, VersionLinks } from '@/lib/derive';
@@ -32,20 +34,7 @@ interface VersionItem {
links?: VersionLinks; links?: VersionLinks;
} }
const MOCK_OVERVIEW: ProductOverview[] = [ 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 },
},
];
interface ProductWithCount { interface ProductWithCount {
id: string; id: string;
@@ -101,8 +90,8 @@ export const useProductStore = create<ProductState>((set, get) => ({
fetchOverview: async () => { fetchOverview: async () => {
if (get().overview.length > 0) return; if (get().overview.length > 0) return;
const cached = loadLocal(); const cached = await loadStoredOverview();
if (cached.length > 0) { if (cached) {
// 有本地缓存直接用不再调远端覆盖mock 模式核心数据在本地) // 有本地缓存直接用不再调远端覆盖mock 模式核心数据在本地)
set({ overview: cached, loading: false }); set({ overview: cached, loading: false });
return; return;
@@ -111,10 +100,11 @@ export const useProductStore = create<ProductState>((set, get) => ({
try { try {
const overview = await api.get<ProductOverview[]>('/products/overview'); const overview = await api.get<ProductOverview[]>('/products/overview');
set({ overview, loading: false }); set({ overview, loading: false });
saveLocal(overview); if (shouldPersistRemoteOverview(overview)) {
saveStoredOverview(overview);
}
} catch { } catch {
set({ overview: MOCK_OVERVIEW, error: null, loading: false }); set({ overview: MOCK_OVERVIEW, error: null, loading: false });
saveLocal(MOCK_OVERVIEW);
} }
}, },
@@ -147,7 +137,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
const updated = [newProduct, ...get().overview]; const updated = [newProduct, ...get().overview];
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
// 异步尝试同步远端(失败也无所谓,本地已更新) // 异步尝试同步远端(失败也无所谓,本地已更新)
try { await api.post('/products', data); } catch {} try { await api.post('/products', data); } catch {}
}, },
@@ -161,7 +151,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
p.id === id ? { ...p, ...data } : p, p.id === id ? { ...p, ...data } : p,
); );
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
} }
}, },
@@ -174,14 +164,14 @@ export const useProductStore = create<ProductState>((set, get) => ({
products: get().products.filter((p) => p.id !== id), products: get().products.filter((p) => p.id !== id),
overview: updated, overview: updated,
}); });
saveLocal(updated); saveStoredOverview(updated);
}, },
reorderProducts: (ids) => { reorderProducts: (ids) => {
const map = new Map(get().overview.map((p) => [p.id, p])); const map = new Map(get().overview.map((p) => [p.id, p]));
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[]; const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
set({ overview: reordered }); set({ overview: reordered });
saveLocal(reordered); saveStoredOverview(reordered);
}, },
migrateAndDeleteProduct: (sourceId, targetId) => { migrateAndDeleteProduct: (sourceId, targetId) => {
@@ -204,7 +194,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
.filter((p) => p.id !== sourceId) .filter((p) => p.id !== sourceId)
.map((p) => (p.id === targetId ? migratedTarget : p)); .map((p) => (p.id === targetId ? migratedTarget : p));
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
createProject: (productId, data) => { createProject: (productId, data) => {
@@ -223,7 +213,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
}); });
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
createVersion: (productId, data) => { createVersion: (productId, data) => {
@@ -244,7 +234,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
}); });
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
updateVersion: (productId, versionId, data) => { updateVersion: (productId, versionId, data) => {
@@ -258,7 +248,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
}); });
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
deleteVersion: (productId, versionId) => { deleteVersion: (productId, versionId) => {
@@ -271,7 +261,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
}); });
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
deleteProject: (productId, projectId) => { deleteProject: (productId, projectId) => {
@@ -284,20 +274,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
}; };
}); });
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveStoredOverview(updated);
}, },
})); }));
const STORAGE_KEY = 'ftb_products_overview_v4'; function saveStoredOverview(data: ProductOverview[]) {
saveServerData('products-overview', data).catch(() => {});
function saveLocal(data: ProductOverview[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
} }
function loadLocal(): ProductOverview[] { async function loadStoredOverview(): Promise<ProductOverview[] | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<ProductOverview[]>('products-overview');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return MOCK_OVERVIEW; return null;
} }

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive'; import type { Priority } from '@/lib/derive';
import { loadServerData, saveServerData } from '@/lib/server-data';
interface RequirementState { interface RequirementState {
requirements: Requirement[]; requirements: Requirement[];
@@ -10,7 +11,7 @@ interface RequirementState {
types: DictItem[]; types: DictItem[];
platforms: DictItem[]; platforms: DictItem[];
loading: boolean; loading: boolean;
fetchRequirements: () => void; fetchRequirements: () => Promise<void>;
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void; createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
updateRequirement: (id: string, data: Partial<Requirement>) => void; updateRequirement: (id: string, data: Partial<Requirement>) => void;
deleteRequirement: (id: string) => void; deleteRequirement: (id: string) => void;
@@ -50,33 +51,17 @@ const PRESET_PLATFORMS: DictItem[] = [
// --- Mock requirements --- // --- Mock requirements ---
const MOCK_REQUIREMENTS: Requirement[] = [ 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' },
];
// --- localStorage helpers --- // --- Server persistence helpers ---
const STORAGE_KEY = 'ftb_requirements_v3'; function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
saveServerData('requirements', state).catch(() => {});
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(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 { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
@@ -91,8 +76,8 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
platforms: PRESET_PLATFORMS, platforms: PRESET_PLATFORMS,
loading: false, loading: false,
fetchRequirements: () => { fetchRequirements: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) { if (cached) {
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms }); set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms });
} }
@@ -110,21 +95,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
}; };
const updated = [...requirements, newReq]; const updated = [...requirements, newReq];
set({ requirements: updated }); set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms }); saveStored({ requirements: updated, sourceTargets, types, platforms });
}, },
updateRequirement: (id, data) => { updateRequirement: (id, data) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r)); const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
set({ requirements: updated }); set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms }); saveStored({ requirements: updated, sourceTargets, types, platforms });
}, },
deleteRequirement: (id) => { deleteRequirement: (id) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.filter((r) => r.id !== id); const updated = requirements.filter((r) => r.id !== id);
set({ requirements: updated }); set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms }); saveStored({ requirements: updated, sourceTargets, types, platforms });
}, },
// --- SourceTarget dict --- // --- SourceTarget dict ---
@@ -133,21 +118,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) }; const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...sourceTargets, item]; const updated = [...sourceTargets, item];
set({ sourceTargets: updated }); set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms }); saveStored({ requirements, sourceTargets: updated, types, platforms });
}, },
updateSourceTarget: (id, name) => { updateSourceTarget: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s)); const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
set({ sourceTargets: updated }); set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms }); saveStored({ requirements, sourceTargets: updated, types, platforms });
}, },
deleteSourceTarget: (id) => { deleteSourceTarget: (id) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.filter((s) => s.id !== id); const updated = sourceTargets.filter((s) => s.id !== id);
set({ sourceTargets: updated }); set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms }); saveStored({ requirements, sourceTargets: updated, types, platforms });
}, },
// --- Type dict --- // --- Type dict ---
@@ -156,21 +141,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...types, item]; const updated = [...types, item];
set({ types: updated }); set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms }); saveStored({ requirements, sourceTargets, types: updated, platforms });
}, },
updateType: (id, name) => { updateType: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = types.map((t) => (t.id === id ? { ...t, name } : t)); const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
set({ types: updated }); set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms }); saveStored({ requirements, sourceTargets, types: updated, platforms });
}, },
deleteType: (id) => { deleteType: (id) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = types.filter((t) => t.id !== id); const updated = types.filter((t) => t.id !== id);
set({ types: updated }); set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms }); saveStored({ requirements, sourceTargets, types: updated, platforms });
}, },
// --- Platform dict --- // --- Platform dict ---
@@ -179,21 +164,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...platforms, item]; const updated = [...platforms, item];
set({ platforms: updated }); set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated }); saveStored({ requirements, sourceTargets, types, platforms: updated });
}, },
updatePlatform: (id, name) => { updatePlatform: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p)); const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
set({ platforms: updated }); set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated }); saveStored({ requirements, sourceTargets, types, platforms: updated });
}, },
deletePlatform: (id) => { deletePlatform: (id) => {
const { requirements, sourceTargets, types, platforms } = get(); const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.filter((p) => p.id !== id); const updated = platforms.filter((p) => p.id !== id);
set({ platforms: updated }); set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated }); saveStored({ requirements, sourceTargets, types, platforms: updated });
}, },
}; };
}); });

View File

@@ -1,25 +1,23 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; 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 saveStored(items: TaskCategory[]) {
saveServerData('task-categories', items).catch(() => {});
function saveLocal(items: TaskCategory[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
} }
function loadLocal(): TaskCategory[] | null { async function loadStored(): Promise<TaskCategory[] | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<TaskCategory[]>('task-categories');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
interface TaskCategoryState { interface TaskCategoryState {
categories: TaskCategory[]; categories: TaskCategory[];
fetchCategories: () => void; fetchCategories: () => Promise<void>;
addCategory: (name: string, group: CategoryGroup, color?: string) => void; addCategory: (name: string, group: CategoryGroup, color?: string) => void;
updateCategory: (id: string, data: Partial<TaskCategory>) => void; updateCategory: (id: string, data: Partial<TaskCategory>) => void;
deleteCategory: (id: string) => boolean; deleteCategory: (id: string) => boolean;
@@ -28,15 +26,16 @@ interface TaskCategoryState {
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
categories: PRESET_CATEGORIES, categories: PRESET_CATEGORIES,
fetchCategories: () => { fetchCategories: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) set({ categories: cached }); if (cached) set({ categories: normalizeTaskCategories(cached) });
}, },
addCategory: (name, group, color) => { addCategory: (name, group, color) => {
const list = get().categories; const list = get().categories;
const item: TaskCategory = { const item: TaskCategory = {
id: `cat-${Date.now()}`, id: `cat-${Date.now()}`,
code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').replace(/^_+|_+$/g, '') || `cat_${Date.now()}`,
name, name,
group, group,
color, color,
@@ -45,13 +44,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
}; };
const updated = [...list, item]; const updated = [...list, item];
set({ categories: updated }); set({ categories: updated });
saveLocal(updated); saveStored(updated);
}, },
updateCategory: (id, data) => { updateCategory: (id, data) => {
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c)); const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated }); set({ categories: updated });
saveLocal(updated); saveStored(updated);
}, },
deleteCategory: (id) => { deleteCategory: (id) => {
@@ -59,7 +58,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
if (!target || target.isSystem) return false; if (!target || target.isSystem) return false;
const updated = get().categories.filter((c) => c.id !== id); const updated = get().categories.filter((c) => c.id !== id);
set({ categories: updated }); set({ categories: updated });
saveLocal(updated); saveStored(updated);
return true; return true;
}, },
})); }));

View File

@@ -1,24 +1,22 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { TaskWorklog } from '@/lib/task-worklog'; import type { TaskWorklog } from '@/lib/task-worklog';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_task_worklogs_v1'; function saveStored(items: TaskWorklog[]) {
saveServerData('task-worklogs', items).catch(() => {});
function saveLocal(items: TaskWorklog[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
} }
function loadLocal(): TaskWorklog[] | null { async function loadStored(): Promise<TaskWorklog[] | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<TaskWorklog[]>('task-worklogs');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
interface TaskWorklogState { interface TaskWorklogState {
worklogs: TaskWorklog[]; worklogs: TaskWorklog[];
fetchWorklogs: () => void; fetchWorklogs: () => Promise<void>;
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void; addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
deleteWorklog: (id: string) => void; deleteWorklog: (id: string) => void;
getActualHours: (taskId: string) => number; getActualHours: (taskId: string) => number;
@@ -27,8 +25,8 @@ interface TaskWorklogState {
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
worklogs: [], worklogs: [],
fetchWorklogs: () => { fetchWorklogs: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) set({ worklogs: cached }); if (cached) set({ worklogs: cached });
}, },
@@ -40,13 +38,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
}; };
const updated = [...get().worklogs, item]; const updated = [...get().worklogs, item];
set({ worklogs: updated }); set({ worklogs: updated });
saveLocal(updated); saveStored(updated);
}, },
deleteWorklog: (id) => { deleteWorklog: (id) => {
const updated = get().worklogs.filter((w) => w.id !== id); const updated = get().worklogs.filter((w) => w.id !== id);
set({ worklogs: updated }); set({ worklogs: updated });
saveLocal(updated); saveStored(updated);
}, },
getActualHours: (taskId) => { getActualHours: (taskId) => {

View File

@@ -1,37 +1,36 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { VersionPlan, PlanType } from '@/lib/version-plan'; import type { VersionPlan, PlanType } from '@/lib/version-plan';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_version_plans_v1'; import { getPlanCompletionState } from '@/lib/version-plan-workflow';
const MOCK_PLANS: VersionPlan[] = []; const MOCK_PLANS: VersionPlan[] = [];
function saveLocal(plans: VersionPlan[]) { function saveStored(plans: VersionPlan[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {} saveServerData('version-plans', plans).catch(() => {});
} }
function loadLocal(): VersionPlan[] | null { async function loadStored(): Promise<VersionPlan[] | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<VersionPlan[]>('version-plans');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
interface VersionPlanState { interface VersionPlanState {
plans: VersionPlan[]; plans: VersionPlan[];
fetchPlans: () => void; fetchPlans: () => Promise<void>;
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void; createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
updatePlan: (id: string, data: Partial<VersionPlan>) => void; updatePlan: (id: string, data: Partial<VersionPlan>) => 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; deletePlan: (id: string) => void;
} }
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
plans: MOCK_PLANS, plans: MOCK_PLANS,
fetchPlans: () => { fetchPlans: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) set({ plans: cached }); if (cached) set({ plans: cached });
}, },
@@ -39,7 +38,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const plans = [...get().plans, plan]; const plans = [...get().plans, plan];
set({ plans }); set({ plans });
saveLocal(plans); saveStored(plans);
}, },
updatePlan: (id, data) => { updatePlan: (id, data) => {
@@ -56,18 +55,30 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
return { ...p, ...patch }; return { ...p, ...patch };
}); });
set({ plans }); set({ plans });
saveLocal(plans); saveStored(plans);
}, },
completePlan: (id, result) => { 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 }); set({ plans });
saveLocal(plans); saveStored(plans);
return response;
}, },
deletePlan: (id) => { deletePlan: (id) => {
const plans = get().plans.filter((p) => p.id !== id); const plans = get().plans.filter((p) => p.id !== id);
set({ plans }); set({ plans });
saveLocal(plans); saveStored(plans);
}, },
})); }));

View File

@@ -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"]
}

101
docs/glossary.md Normal file
View File

@@ -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 等不同实体统一为同一形状,给"与我相关"页面用。

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,11 @@
"name": "@ftb/shared", "name": "@ftb/shared",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"lint": "eslint src/" "lint": "eslint src/"
}, },

View File

@@ -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;
}

View File

@@ -1,2 +1,3 @@
export * from './enums'; export * from './enums';
export * from './types'; export * from './types';
export * from './agent';

View File

@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"module": "ESNext", "module": "CommonJS",
"moduleResolution": "bundler", "moduleResolution": "node",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,

171
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
apps/server: apps/server:
dependencies: dependencies:
'@anthropic-ai/sdk':
specifier: ^0.27.0
version: 0.27.3
'@ftb/shared': '@ftb/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
@@ -41,6 +44,9 @@ importers:
class-validator: class-validator:
specifier: ^0.15.1 specifier: ^0.15.1
version: 0.15.1 version: 0.15.1
openai:
specifier: ^4.104.0
version: 4.104.0
reflect-metadata: reflect-metadata:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.2 version: 0.2.2
@@ -161,6 +167,9 @@ packages:
resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} 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'} 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': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -774,6 +783,12 @@ packages:
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} 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': '@types/node@20.19.42':
resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==}
@@ -866,6 +881,10 @@ packages:
'@xtuc/long@4.2.2': '@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} 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: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -879,6 +898,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-formats@2.1.1: ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies: peerDependencies:
@@ -962,6 +985,9 @@ packages:
array-timsort@1.0.3: array-timsort@1.0.3:
resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.5.0: autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1164,6 +1190,10 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 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: commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 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==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1350,6 +1384,10 @@ packages:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'} 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: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1390,6 +1428,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} 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: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@@ -1479,6 +1521,17 @@ packages:
typescript: '>3.6.0' typescript: '>3.6.0'
webpack: ^5.11.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: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1578,6 +1631,10 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4: hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1593,6 +1650,9 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'} 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: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2071,6 +2131,11 @@ packages:
node-abort-controller@3.1.1: node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} 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: node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
@@ -2121,6 +2186,18 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} 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: ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2785,6 +2862,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -2842,6 +2922,10 @@ packages:
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} 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: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -2971,6 +3055,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - 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': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -3725,6 +3821,15 @@ snapshots:
'@types/mime@1.3.5': {} '@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': '@types/node@20.19.42':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -3849,6 +3954,10 @@ snapshots:
'@xtuc/long@4.2.2': {} '@xtuc/long@4.2.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@@ -3860,6 +3969,10 @@ snapshots:
acorn@8.16.0: {} acorn@8.16.0: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@2.1.1(ajv@8.12.0): ajv-formats@2.1.1(ajv@8.12.0):
optionalDependencies: optionalDependencies:
ajv: 8.12.0 ajv: 8.12.0
@@ -3939,6 +4052,8 @@ snapshots:
array-timsort@1.0.3: {} array-timsort@1.0.3: {}
asynckit@0.4.0: {}
autoprefixer@10.5.0(postcss@8.5.15): autoprefixer@10.5.0(postcss@8.5.15):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
@@ -4176,6 +4291,10 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {} commander@2.20.3: {}
commander@4.1.1: {} commander@4.1.1: {}
@@ -4276,6 +4395,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
delayed-stream@1.0.0: {}
depd@2.0.0: {} depd@2.0.0: {}
destroy@1.2.0: {} destroy@1.2.0: {}
@@ -4329,6 +4450,13 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 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: {} escalade@3.2.0: {}
escape-html@1.0.3: {} escape-html@1.0.3: {}
@@ -4354,6 +4482,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
events@3.3.0: {} events@3.3.0: {}
execa@5.1.1: execa@5.1.1:
@@ -4506,6 +4636,21 @@ snapshots:
typescript: 5.7.2 typescript: 5.7.2
webpack: 5.97.1 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: {} forwarded@0.2.0: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
@@ -4604,6 +4749,10 @@ snapshots:
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4: hasown@2.0.4:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -4620,6 +4769,10 @@ snapshots:
human-signals@2.1.0: {} human-signals@2.1.0: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
iconv-lite@0.4.24: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -5269,6 +5422,8 @@ snapshots:
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
node-domexception@1.0.0: {}
node-emoji@1.11.0: node-emoji@1.11.0:
dependencies: dependencies:
lodash: 4.18.1 lodash: 4.18.1
@@ -5305,6 +5460,18 @@ snapshots:
dependencies: dependencies:
mimic-fn: 2.1.0 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: ora@5.4.1:
dependencies: dependencies:
bl: 4.1.0 bl: 4.1.0
@@ -5920,6 +6087,8 @@ snapshots:
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
universalify@2.0.1: {} universalify@2.0.1: {}
@@ -5969,6 +6138,8 @@ snapshots:
dependencies: dependencies:
defaults: 1.0.4 defaults: 1.0.4
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
webpack-node-externals@3.0.0: {} webpack-node-externals@3.0.0: {}