feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
apps/server/data/
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -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 能力的项目管理平台,核心层级:产品 → 项目 → 迭代 → 任务。
|
||||||
|
|||||||
@@ -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 Key(V3.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
|
||||||
|
|||||||
9
apps/server/jest.config.js
Normal file
9
apps/server/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
rootDir: '.',
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)s$': 'ts-jest',
|
||||||
|
},
|
||||||
|
testEnvironment: 'node',
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
// 加载 .env(Node v20+ 内置 loadEnvFile)
|
||||||
|
// 优先 cwd/.env,其次 dist 上一级(编译运行时 cwd 可能在 dist/)
|
||||||
|
for (const candidate of [resolve(process.cwd(), '.env'), resolve(__dirname, '../../.env'), resolve(__dirname, '../.env')]) {
|
||||||
|
if (existsSync(candidate) && typeof (process as any).loadEnvFile === 'function') {
|
||||||
|
(process as any).loadEnvFile(candidate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|||||||
37
apps/server/src/modules/ai/ai-gateway.service.ts
Normal file
37
apps/server/src/modules/ai/ai-gateway.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/server/src/modules/ai/ai.controller.ts
Normal file
14
apps/server/src/modules/ai/ai.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/modules/ai/ai.module.ts
Normal file
13
apps/server/src/modules/ai/ai.module.ts
Normal 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 {}
|
||||||
155
apps/server/src/modules/ai/ai.service.spec.ts
Normal file
155
apps/server/src/modules/ai/ai.service.spec.ts
Normal 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_use;content=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
|
||||||
|
|
||||||
|
describe('AiService', () => {
|
||||||
|
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
||||||
|
const callTool = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error(missingToolUseMessage))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
toolName: 'submit_decompose',
|
||||||
|
toolInput: {
|
||||||
|
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||||
|
devTaskDrafts: [],
|
||||||
|
testCaseDrafts: [],
|
||||||
|
},
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 20,
|
||||||
|
rawModel: 'claude-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
|
||||||
|
`${'无关内容'.repeat(5000)} QY0001:手机号登录。${'噪声内容'.repeat(5000)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.decompose({
|
||||||
|
prototypeUrl: 'https://example.com/prototype',
|
||||||
|
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
|
||||||
|
members: [{ name: '张三', role: 'frontend' }],
|
||||||
|
versionId: 'version-1',
|
||||||
|
planId: 'plan-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(2);
|
||||||
|
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
|
||||||
|
const retryPrompt = callTool.mock.calls[1][0].userPrompt;
|
||||||
|
expect(retryPrompt.length).toBeLessThan(firstPrompt.length);
|
||||||
|
expect(retryPrompt).toContain('重试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps shrinking prototype context when the first retry still returns no tool_use', async () => {
|
||||||
|
const callTool = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error(missingToolUseMessage))
|
||||||
|
.mockRejectedValueOnce(new Error(missingToolUseMessage))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
toolName: 'submit_decompose',
|
||||||
|
toolInput: {
|
||||||
|
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
||||||
|
devTaskDrafts: [],
|
||||||
|
testCaseDrafts: [],
|
||||||
|
},
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 20,
|
||||||
|
rawModel: 'claude-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
|
||||||
|
Array.from({ length: 30 }, (_, i) => `QY${String(i + 1).padStart(4, '0')}:${'内容'.repeat(300)}`).join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.decompose({
|
||||||
|
prototypeUrl: 'https://example.com/prototype',
|
||||||
|
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
|
||||||
|
members: [{ name: '张三', role: 'frontend' }],
|
||||||
|
versionId: 'version-1',
|
||||||
|
planId: 'plan-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(callTool).toHaveBeenCalledTimes(3);
|
||||||
|
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
|
||||||
|
const secondPrompt = callTool.mock.calls[1][0].userPrompt;
|
||||||
|
const thirdPrompt = callTool.mock.calls[2][0].userPrompt;
|
||||||
|
expect(secondPrompt.length).toBeLessThan(firstPrompt.length);
|
||||||
|
expect(thirdPrompt.length).toBeLessThan(secondPrompt.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries prototype fetch before returning fetch failure', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchMock = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => '<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
223
apps/server/src/modules/ai/ai.service.ts
Normal file
223
apps/server/src/modules/ai/ai.service.ts
Normal 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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRetryWithShortContext(error: any): boolean {
|
||||||
|
const msg = error?.message || '';
|
||||||
|
return (
|
||||||
|
msg.includes('Anthropic 未通过 tool_use 返回结果') &&
|
||||||
|
(msg.includes('上游模型未返回任何内容') || msg.includes('stop_reason=tool_use'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<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 返回结果。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/server/src/modules/ai/dto/decompose.dto.ts
Normal file
46
apps/server/src/modules/ai/dto/decompose.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
186
apps/server/src/modules/ai/prompts/decompose.ts
Normal file
186
apps/server/src/modules/ai/prompts/decompose.ts
Normal 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'],
|
||||||
|
};
|
||||||
27
apps/server/src/modules/ai/prototype-context.spec.ts
Normal file
27
apps/server/src/modules/ai/prototype-context.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/server/src/modules/ai/prototype-context.ts
Normal file
72
apps/server/src/modules/ai/prototype-context.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal file
106
apps/server/src/modules/ai/providers/anthropic.provider.spec.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal file
88
apps/server/src/modules/ai/providers/anthropic.provider.ts
Normal 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(';');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/server/src/modules/ai/providers/factory.ts
Normal file
14
apps/server/src/modules/ai/providers/factory.ts
Normal 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}`);
|
||||||
|
}
|
||||||
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal file
79
apps/server/src/modules/ai/providers/openai.provider.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal file
49
apps/server/src/modules/ai/providers/provider.interface.ts
Normal 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;
|
||||||
|
/** 必须强制走 tool(Anthropic: 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>;
|
||||||
|
}
|
||||||
76
apps/server/src/modules/config/ai-config.controller.ts
Normal file
76
apps/server/src/modules/config/ai-config.controller.ts
Normal 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 || '调用失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
apps/server/src/modules/config/ai-config.service.ts
Normal file
200
apps/server/src/modules/config/ai-config.service.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/server/src/modules/config/config.module.ts
Normal file
10
apps/server/src/modules/config/config.module.ts
Normal 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 {}
|
||||||
44
apps/server/src/modules/config/dto/update-ai-config.dto.ts
Normal file
44
apps/server/src/modules/config/dto/update-ai-config.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/server/src/modules/data/data-keys.ts
Normal file
18
apps/server/src/modules/data/data-keys.ts
Normal 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);
|
||||||
|
}
|
||||||
17
apps/server/src/modules/data/data.controller.ts
Normal file
17
apps/server/src/modules/data/data.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/server/src/modules/data/data.module.ts
Normal file
9
apps/server/src/modules/data/data.module.ts
Normal 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 {}
|
||||||
66
apps/server/src/modules/data/data.service.spec.ts
Normal file
66
apps/server/src/modules/data/data.service.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/server/src/modules/data/data.service.ts
Normal file
32
apps/server/src/modules/data/data.service.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
503
apps/web/app/admin/ai-config/page.tsx
Normal file
503
apps/web/app/admin/ai-config/page.tsx
Normal 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-6、claude-opus-4-7、gpt-4o、deepseek-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />阻塞
|
||||||
|
|||||||
@@ -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: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
206
apps/web/components/version/AiDecomposeButton.tsx
Normal file
206
apps/web/components/version/AiDecomposeButton.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
apps/web/components/version/DecomposeReportModal.tsx
Normal file
381
apps/web/components/version/DecomposeReportModal.tsx
Normal 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 类型 id(AI 给的是 code)映射回内部 id
|
||||||
|
const normalizeRefs = (refs: AgentDevTaskDraft['references'] | AgentTestCaseDraft['references']) =>
|
||||||
|
refs.map((ref) => {
|
||||||
|
if (ref.type === 'requirement') {
|
||||||
|
const r = reqByCode.get(ref.id) ?? reqByInternalId.get(ref.id);
|
||||||
|
if (r) return { ...ref, id: r.id, label: ref.label || `${r.code} ${r.title}` };
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
});
|
||||||
|
|
||||||
|
let devCount = 0;
|
||||||
|
for (let i = 0; i < data.devTaskDrafts.length; i++) {
|
||||||
|
if (!selectedDevIdx.has(i)) continue;
|
||||||
|
const draft = data.devTaskDrafts[i];
|
||||||
|
const refs = normalizeRefs(draft.references);
|
||||||
|
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||||
|
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||||
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||||
|
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||||
|
const startISO = new Date().toISOString();
|
||||||
|
const endISO = addWorkHours(startISO, estimateHours);
|
||||||
|
|
||||||
|
createTask({
|
||||||
|
requirementId,
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
categoryId,
|
||||||
|
assigneeId: '',
|
||||||
|
reviewerId: undefined,
|
||||||
|
priority: draft.priority,
|
||||||
|
expectedStartAt: startISO,
|
||||||
|
expectedEndAt: endISO,
|
||||||
|
estimateHours,
|
||||||
|
actualStartAt: undefined,
|
||||||
|
actualEndAt: undefined,
|
||||||
|
status: 'todo',
|
||||||
|
blockReason: undefined,
|
||||||
|
blockedById: undefined,
|
||||||
|
predecessorIds: undefined,
|
||||||
|
riskLevel: undefined,
|
||||||
|
delayReason: undefined,
|
||||||
|
overdueVersionReason: undefined,
|
||||||
|
references: refs,
|
||||||
|
aiDraft: true,
|
||||||
|
aiDraftAt: now,
|
||||||
|
createdBy: user?.name || 'AI',
|
||||||
|
} as any);
|
||||||
|
devCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tcCount = 0;
|
||||||
|
for (let i = 0; i < data.testCaseDrafts.length; i++) {
|
||||||
|
if (!selectedTcIdx.has(i)) continue;
|
||||||
|
const draft = data.testCaseDrafts[i];
|
||||||
|
const refs = normalizeRefs(draft.references);
|
||||||
|
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||||
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||||
|
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||||
|
|
||||||
|
createTestCase({
|
||||||
|
versionId: version.id,
|
||||||
|
requirementId: reqRef?.id,
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
categoryId,
|
||||||
|
priority: draft.priority,
|
||||||
|
estimateHours,
|
||||||
|
assigneeId: undefined,
|
||||||
|
references: refs,
|
||||||
|
aiDraft: true,
|
||||||
|
aiDraftAt: now,
|
||||||
|
createdBy: user?.name || 'AI',
|
||||||
|
} as any);
|
||||||
|
tcCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
// 1.5 秒后自动关闭
|
||||||
|
setTimeout(() => onClose(), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
12
apps/web/lib/product-overview-persistence.test.ts
Normal file
12
apps/web/lib/product-overview-persistence.test.ts
Normal 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);
|
||||||
|
});
|
||||||
3
apps/web/lib/product-overview-persistence.ts
Normal file
3
apps/web/lib/product-overview-persistence.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function shouldPersistRemoteOverview(overview: unknown): boolean {
|
||||||
|
return Array.isArray(overview) && overview.length > 0;
|
||||||
|
}
|
||||||
49
apps/web/lib/requirement-selector.test.ts
Normal file
49
apps/web/lib/requirement-selector.test.ts
Normal 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);
|
||||||
|
});
|
||||||
42
apps/web/lib/requirement-selector.ts
Normal file
42
apps/web/lib/requirement-selector.ts
Normal 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];
|
||||||
|
}
|
||||||
27
apps/web/lib/server-data.ts
Normal file
27
apps/web/lib/server-data.ts
Normal 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 });
|
||||||
|
}
|
||||||
38
apps/web/lib/task-category.test.ts
Normal file
38
apps/web/lib/task-category.test.ts
Normal 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, '后端接口');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
85
apps/web/lib/version-plan-workflow.test.ts
Normal file
85
apps/web/lib/version-plan-workflow.test.ts
Normal 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);
|
||||||
|
});
|
||||||
76
apps/web/lib/version-plan-workflow.ts
Normal file
76
apps/web/lib/version-plan-workflow.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
25
apps/web/scripts/run-node-tests.mjs
Normal file
25
apps/web/scripts/run-node-tests.mjs
Normal 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);
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
15
apps/web/tsconfig.test.json
Normal file
15
apps/web/tsconfig.test.json
Normal 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
101
docs/glossary.md
Normal 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 等不同实体统一为同一形状,给"与我相关"页面用。
|
||||||
1499
docs/superpowers/plans/2026-06-25-workflow-effort-engine.md
Normal file
1499
docs/superpowers/plans/2026-06-25-workflow-effort-engine.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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/"
|
||||||
},
|
},
|
||||||
|
|||||||
167
packages/shared/src/agent.ts
Normal file
167
packages/shared/src/agent.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './enums';
|
export * from './enums';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export * from './agent';
|
||||||
|
|||||||
@@ -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
171
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user