feat(平台): 补齐服务端持久化和AI拆解契约
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
# 后端 NestJS 环境变量 — 复制为 .env 后填入真实值
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm
|
||||
# Anthropic API Key(V3.1 起 AI 拆解 Agent 必需)
|
||||
# 申请:https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.27.0",
|
||||
"@ftb/shared": "workspace:*",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@prisma/client": "^5.15.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"openai": "^4.104.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
|
||||
@@ -167,3 +167,12 @@ model ProjectMember {
|
||||
@@unique([projectId, userId])
|
||||
@@map("project_members")
|
||||
}
|
||||
|
||||
model AppData {
|
||||
key String @id
|
||||
value Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("app_data")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ConfigModule } from './modules/config/config.module';
|
||||
import { DataModule } from './modules/data/data.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
// 加载 .env(Node v20+ 内置 loadEnvFile)
|
||||
// 优先 cwd/.env,其次 dist 上一级(编译运行时 cwd 可能在 dist/)
|
||||
for (const candidate of [resolve(process.cwd(), '.env'), resolve(__dirname, '../../.env'), resolve(__dirname, '../.env')]) {
|
||||
if (existsSync(candidate) && typeof (process as any).loadEnvFile === 'function') {
|
||||
(process as any).loadEnvFile(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
private connected = false;
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
try {
|
||||
await this.$connect();
|
||||
this.connected = true;
|
||||
this.logger.log('Prisma connected');
|
||||
} catch (e: any) {
|
||||
this.logger.warn(
|
||||
`数据库连接失败 (${e?.errorCode || e?.message || e}) — 跳过,需要 DB 的接口将不可用,但不影响 AI 配置 / 拆解模块`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
if (this.connected) {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { LayoutGrid, Phone, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { Eye, EyeOff, LayoutGrid, Lock, Phone } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -15,7 +15,7 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
@@ -25,21 +25,21 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
const success = login(phone.trim(), password, remember);
|
||||
try {
|
||||
const success = await login(phone.trim(), password, remember);
|
||||
if (success) {
|
||||
router.push('/products');
|
||||
} else {
|
||||
setError('手机号或密码错误');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<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">
|
||||
<LayoutGrid className="h-6 w-6 text-white" strokeWidth={2} />
|
||||
@@ -48,59 +48,57 @@ export default function LoginPage() {
|
||||
<p className="mt-1 text-[13px] text-[var(--ink-muted)]">登录以继续使用系统</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<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">
|
||||
<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">
|
||||
<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
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
|
||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
|
||||
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)] focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">保持登录</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)] focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">保持登录</span>
|
||||
</label>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -108,9 +106,9 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
|
||||
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
|
||||
@@ -351,9 +353,25 @@ export default function ProjectDetailPage() {
|
||||
|
||||
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentUserName = user?.name || '';
|
||||
const { roles } = useMemberStore();
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const r = roles.find((x) => x.id === user?.roleId);
|
||||
return !!r && r.permissions.includes('*');
|
||||
}, [roles, user?.roleId]);
|
||||
|
||||
const sortedVersions = useMemo(() => {
|
||||
if (!project) return [];
|
||||
let list = [...project.versions];
|
||||
// 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部)
|
||||
if (!isSuperAdmin) {
|
||||
list = list.filter((v) => {
|
||||
const ms = v.members ?? [];
|
||||
if (ms.length === 0) return true;
|
||||
return ms.some((m) => m.name === currentUserName);
|
||||
});
|
||||
}
|
||||
if (statusFilter !== 'all') {
|
||||
if (statusFilter === 'planned') {
|
||||
list = list.filter((v) => v.status === 'planned');
|
||||
@@ -364,7 +382,7 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [project, statusFilter]);
|
||||
}, [project, statusFilter, isSuperAdmin, currentUserName]);
|
||||
|
||||
// Compute actual overall progress per version
|
||||
const versionProgressMap = useMemo(() => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { testCaseIntervals } from '@/lib/test-case';
|
||||
import { bugIntervals } from '@/lib/bug';
|
||||
import { planIntervals } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||
|
||||
const PRIORITY_STYLE: Record<string, string> = {
|
||||
P0: 'bg-red-500/10 text-red-600',
|
||||
@@ -145,9 +146,10 @@ export default function VersionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 权限校验:只有参与人员可以访问
|
||||
// 权限校验:只有参与人员可以访问(超管不受限)
|
||||
const currentUserName = user?.name || '';
|
||||
const isMember = (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
|
||||
const isSuperAdmin = !!currentRole && currentRole.permissions.includes('*');
|
||||
const isMember = isSuperAdmin || (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
|
||||
if (!isMember) {
|
||||
return (
|
||||
<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) => (
|
||||
<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" />}
|
||||
<span className="truncate">{p.resultFileName || p.title}</span>
|
||||
<span className="truncate">{p.resultTitle || p.resultFileName || p.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -887,17 +889,18 @@ export default function VersionDetailPage() {
|
||||
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||
(() => {
|
||||
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
|
||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
|
||||
return (
|
||||
<PlanTab
|
||||
plans={plans}
|
||||
versionId={version.id}
|
||||
version={version}
|
||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||
currentUserName={user?.name ?? ''}
|
||||
planType={pt}
|
||||
versionMembers={version.members ?? []}
|
||||
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
|
||||
linkedRequirements={projectAdoptedReqs}
|
||||
allRequirements={requirements}
|
||||
onCreate={(data) => {
|
||||
createPlan(data);
|
||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||
@@ -123,13 +124,19 @@ function VersionsPageContent() {
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentUserName = user?.name || '';
|
||||
const { roles } = useMemberStore();
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const r = roles.find((x) => x.id === user?.roleId);
|
||||
return !!r && r.permissions.includes('*');
|
||||
}, [roles, user?.roleId]);
|
||||
|
||||
const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]);
|
||||
// 只显示当前用户参与的版本(members为空时所有人可见)
|
||||
// 只显示当前用户参与的版本(members为空时所有人可见;超管可见全部)
|
||||
const allVersions = useMemo(() => allVersionsRaw.filter((v) => {
|
||||
if (isSuperAdmin) return true;
|
||||
if (!v.members || v.members.length === 0) return true;
|
||||
return v.members.some((m) => m.name === currentUserName);
|
||||
}), [allVersionsRaw, currentUserName]);
|
||||
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
|
||||
// Compute overall progress per version from actual data
|
||||
|
||||
@@ -51,12 +51,17 @@ export function DevTaskRow({ task, category, onClick }: Props) {
|
||||
return (
|
||||
<div
|
||||
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="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">
|
||||
<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 && (
|
||||
<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" />阻塞
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
@@ -23,6 +23,7 @@ const NAV_GROUPS = [
|
||||
items: [
|
||||
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 { formatDateTime } from '@/lib/format';
|
||||
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
@@ -26,6 +27,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
const [showTransfer, setShowTransfer] = useState(false);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||
const [resultTitle, setResultTitle] = useState('');
|
||||
const [resultUrl, setResultUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [fileData, setFileData] = useState('');
|
||||
@@ -34,20 +36,22 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
const plan = plans.find((p) => p.id === planId);
|
||||
if (!plan) return null;
|
||||
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const isResearch = plan.type === 'research';
|
||||
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
|
||||
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||||
const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date());
|
||||
const canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
|
||||
const handleToggleTask = (task: PlanTask) => {
|
||||
if (!canInteract) return;
|
||||
if (!canToggle) return;
|
||||
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
||||
updatePlan(plan.id, { tasks: updatedTasks });
|
||||
};
|
||||
|
||||
const handleToggleReq = (reqId: string) => {
|
||||
if (!canInteract) return;
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
|
||||
updatePlan(plan.id, { completedRequirementIds: next });
|
||||
@@ -64,8 +68,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
|
||||
const handleSubmitResult = () => {
|
||||
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
||||
if (!url) return;
|
||||
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
||||
const title = resultTitle.trim();
|
||||
if (!url || !title) return;
|
||||
const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
||||
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
||||
alert(response.message || '计划未满足完成条件');
|
||||
return;
|
||||
}
|
||||
setShowComplete(false);
|
||||
};
|
||||
|
||||
@@ -138,9 +147,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{plan.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
disabled={!canToggle}
|
||||
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} />}
|
||||
</button>
|
||||
@@ -151,7 +160,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
)}
|
||||
|
||||
{/* Product/UI: Linked Requirements */}
|
||||
{!isResearch && linkedReqs.length > 0 && (
|
||||
{linkedReqs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">关联需求</div>
|
||||
{linkedReqs.map((req) => {
|
||||
@@ -159,9 +168,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
return (
|
||||
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
||||
<button
|
||||
disabled={!canInteract}
|
||||
disabled={!canEditCoverage}
|
||||
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} />}
|
||||
</button>
|
||||
@@ -170,6 +179,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -180,7 +192,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
<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)]" />}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,6 +224,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{showComplete && (
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -225,7 +238,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,21 +247,26 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
|
||||
{/* Footer Actions */}
|
||||
{plan.status !== 'completed' && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||
{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">
|
||||
<Play className="h-3 w-3" />开始
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||
{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">
|
||||
<Play className="h-3 w-3" />开始
|
||||
</button>
|
||||
)}
|
||||
{plan.status === 'in_progress' && (
|
||||
<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 onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
||||
<ArrowRightLeft className="h-3 w-3" />转交
|
||||
</button>
|
||||
</div>
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
||||
<ArrowRightLeft className="h-3 w-3" />转交
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
||||
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
|
||||
interface Props {
|
||||
plans: VersionPlan[];
|
||||
versionId: string;
|
||||
version?: VersionWithContext;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionMembers: { role: string; name: string }[];
|
||||
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => 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;
|
||||
}
|
||||
|
||||
@@ -29,7 +36,7 @@ const STATUS_STYLE = {
|
||||
};
|
||||
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
||||
|
||||
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<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 effectiveStatus = autoStarted ? 'in_progress' : plan.status;
|
||||
const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null);
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
// 耗时用实际时间戳计算
|
||||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||||
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
|
||||
@@ -102,11 +113,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
<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)]" />}
|
||||
<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>
|
||||
{planType === 'product' && version && (
|
||||
<AiDecomposeButton plan={plan} version={version} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 调研:任务进度 */}
|
||||
{/* 子任务 */}
|
||||
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -119,14 +133,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
{plan.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={plan.status !== 'in_progress' && !autoStarted}
|
||||
disabled={!canToggle}
|
||||
onClick={() => {
|
||||
if (plan.status !== 'in_progress' && !autoStarted) return;
|
||||
if (!canToggle) return;
|
||||
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
||||
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} />}
|
||||
</button>
|
||||
@@ -139,8 +153,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
</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="flex items-center gap-2">
|
||||
<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 className="space-y-1">
|
||||
{plan.linkedRequirementIds.map((rid) => {
|
||||
const req = linkedRequirements.find((r) => r.id === rid);
|
||||
const req = requirementOptions.find((r) => r.id === rid);
|
||||
const isDone = (plan.completedRequirementIds || []).includes(rid);
|
||||
return req ? (
|
||||
<div key={rid} className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={plan.status !== 'in_progress' && !autoStarted}
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => {
|
||||
if (plan.status !== 'in_progress' && !autoStarted) return;
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
|
||||
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} />}
|
||||
</button>
|
||||
@@ -172,14 +186,11 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
) : null;
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
{plan.status === 'pending' && !autoStarted && (
|
||||
@@ -187,11 +198,6 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
<Play className="h-3 w-3" />开始
|
||||
</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' && (
|
||||
<>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -232,6 +244,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
versionDeadline={versionDeadline}
|
||||
currentUserName={currentUserName}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editingPlan) onUpdate(editingPlan.id, data);
|
||||
@@ -245,20 +258,28 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
||||
{completingPlan && (
|
||||
<CompleteModal
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||
initial: VersionPlan | null;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionId: string;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
linkedRequirements?: { id: string; title: string; code: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}) {
|
||||
@@ -273,7 +294,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||||
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
||||
const [endTimeError, setEndTimeError] = useState('');
|
||||
const showReqSelect = planType === 'product' || planType === 'ui';
|
||||
const requirementOptions = useMemo(
|
||||
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
|
||||
[linkedRequirements, allRequirements, selectedReqs],
|
||||
);
|
||||
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -294,8 +318,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
startTime,
|
||||
endTime,
|
||||
status: initial?.status ?? 'pending',
|
||||
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
|
||||
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
|
||||
remark: remark.trim() || undefined,
|
||||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
||||
addedBy: currentUserName,
|
||||
@@ -345,63 +369,83 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
|
||||
{requirementOptions.length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
{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]">
|
||||
<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)] 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{planType === 'research' && (
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||
任务清单 <span className="text-red-500">*</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)] ml-1">至少添加一项</span>
|
||||
</label>
|
||||
{/* 预设选项 */}
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
|
||||
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
+ {preset}
|
||||
</button>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||
调研方向 <span className="text-red-500">*</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)] ml-1">至少添加一项</span>
|
||||
</label>
|
||||
{/* 预设选项 */}
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
|
||||
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
+ {preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
|
||||
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
|
||||
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
|
||||
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
|
||||
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newTaskTitle}
|
||||
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(''); } } }}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-[11px] text-red-500 mt-1">请至少添加一项调研方向</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newTaskTitle}
|
||||
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(''); } } }}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-[11px] text-red-500 mt-1">请至少添加一项任务</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
||||
@@ -419,9 +463,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
|
||||
function CompleteModal({ onClose, onSubmit }: {
|
||||
onClose: () => void;
|
||||
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||
onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||
}) {
|
||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||
const [resultTitle, setResultTitle] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [fileData, setFileData] = useState('');
|
||||
@@ -435,7 +480,7 @@ function CompleteModal({ onClose, onSubmit }: {
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
|
||||
const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -459,7 +508,7 @@ function CompleteModal({ onClose, onSubmit }: {
|
||||
)}
|
||||
<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={() => 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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Requirement, ChangeReason } from '@/lib/requirement';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
|
||||
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||
|
||||
interface Props {
|
||||
versionId: string;
|
||||
@@ -23,7 +24,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
|
||||
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
|
||||
const availableReqs = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -43,7 +44,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<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="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)]">
|
||||
<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>
|
||||
</div>
|
||||
<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)]" />
|
||||
<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>
|
||||
{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 className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{filtered.length === 0 ? (
|
||||
|
||||
@@ -8,7 +8,8 @@ async function checkApi(): Promise<boolean> {
|
||||
if (probePromise) return probePromise;
|
||||
probePromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products`, { method: 'HEAD', signal: AbortSignal.timeout(300) });
|
||||
// 探测一个不依赖数据库的端点(/config/ai 总是返回 200,只要 NestJS 起来了)
|
||||
const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) });
|
||||
apiAvailable = res.ok;
|
||||
} catch {
|
||||
apiAvailable = false;
|
||||
@@ -37,7 +38,29 @@ export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data: unknown) =>
|
||||
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) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
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 {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
group: CategoryGroup;
|
||||
color?: string;
|
||||
@@ -11,19 +14,68 @@ export interface TaskCategory {
|
||||
|
||||
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
|
||||
development: '开发',
|
||||
testing: '测试',
|
||||
implementation: '实施',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const PRESET_CATEGORIES: TaskCategory[] = [
|
||||
{ id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
||||
{ id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true },
|
||||
{ id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true },
|
||||
{ id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true },
|
||||
{ id: 'cat-5', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-6', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 6, isSystem: true },
|
||||
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
||||
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true },
|
||||
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true },
|
||||
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 4, isSystem: true },
|
||||
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
|
||||
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
|
||||
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
|
||||
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
|
||||
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
|
||||
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
|
||||
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
|
||||
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
|
||||
];
|
||||
|
||||
const LEGACY_CODE_BY_ID: Record<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 {
|
||||
return categories.find((c) => c.id === id);
|
||||
}
|
||||
@@ -31,3 +83,16 @@ export function getCategoryById(categories: TaskCategory[], id: string): TaskCat
|
||||
export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
|
||||
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined {
|
||||
if (!code) return undefined;
|
||||
return categories.find((c) => c.code === code);
|
||||
}
|
||||
|
||||
export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
|
||||
return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
|
||||
}
|
||||
|
||||
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
|
||||
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
|
||||
}
|
||||
|
||||
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[];
|
||||
linkedRequirementIds?: string[];
|
||||
resultType?: 'link' | 'file';
|
||||
resultTitle?: string;
|
||||
resultUrl?: string;
|
||||
resultFileName?: string;
|
||||
resultFileData?: string;
|
||||
@@ -28,6 +29,10 @@ export interface VersionPlan {
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
addedBy: string;
|
||||
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
|
||||
aiDecomposeAt?: string;
|
||||
aiDecomposeBy?: string;
|
||||
aiDecomposeError?: string;
|
||||
}
|
||||
|
||||
export type PlanType = VersionPlan['type'];
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"dev": "node --max-old-space-size=4096 node_modules/next/dist/bin/next dev --port 3000",
|
||||
"build": "node --max-old-space-size=4096 node_modules/next/dist/bin/next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
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';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { loadServerData } from '@/lib/server-data';
|
||||
import type { Member } from '@/lib/members';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
@@ -14,53 +17,63 @@ interface AuthUser {
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (phone: string, password: string, remember: boolean) => boolean;
|
||||
login: (phone: string, password: string, remember: boolean) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => void;
|
||||
refreshUser: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface StoredMembersData {
|
||||
members: Member[];
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'ftb_auth_session';
|
||||
const PERSIST_KEY = 'ftb_auth_persist';
|
||||
|
||||
const FALLBACK_MEMBERS: Member[] = [
|
||||
{
|
||||
id: 'm-8',
|
||||
name: '\u8d85\u7ea7\u7ba1\u7406\u5458',
|
||||
departmentId: 'dept-1',
|
||||
roleId: 'role-admin',
|
||||
phone: '13200132008',
|
||||
email: 'chenshi@company.com',
|
||||
password: 'Ftb@2024',
|
||||
createdAt: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
async function loadMembersForAuth(): Promise<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;
|
||||
}
|
||||
|
||||
function toAuthUser(member: Member): AuthUser {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
roleId: member.roleId,
|
||||
departmentId: member.departmentId,
|
||||
phone: member.phone,
|
||||
email: member.email,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (phone, password, remember) => {
|
||||
const membersRaw = localStorage.getItem('ftb_members_v1');
|
||||
let members: any[] = [];
|
||||
if (membersRaw) {
|
||||
const parsed = JSON.parse(membersRaw);
|
||||
members = parsed.members || [];
|
||||
}
|
||||
|
||||
// fallback: 如果 localStorage 没有数据或为空,使用默认 mock
|
||||
if (members.length === 0) {
|
||||
members = [
|
||||
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024' },
|
||||
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024' },
|
||||
];
|
||||
}
|
||||
|
||||
const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password);
|
||||
login: async (phone, password, remember) => {
|
||||
const members = await loadMembersForAuth();
|
||||
const member = members.find((m) => m.phone === phone && (m.password || 'Ftb@2024') === password);
|
||||
if (!member) return false;
|
||||
|
||||
const user: AuthUser = {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
roleId: member.roleId,
|
||||
departmentId: member.departmentId,
|
||||
phone: member.phone,
|
||||
email: member.email,
|
||||
};
|
||||
|
||||
const user = toAuthUser(member);
|
||||
set({ user, isAuthenticated: true });
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
|
||||
if (remember) {
|
||||
@@ -90,16 +103,16 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
refreshUser: () => {
|
||||
refreshUser: async () => {
|
||||
const cur = useAuthStore.getState().user;
|
||||
if (!cur) return;
|
||||
try {
|
||||
const raw = localStorage.getItem('ftb_members_v1');
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
const m = parsed.members?.find((x: any) => x.id === cur.id);
|
||||
if (!m) return;
|
||||
const next: AuthUser = { id: m.id, name: m.name, roleId: m.roleId, departmentId: m.departmentId, phone: m.phone, email: m.email };
|
||||
const memoryMember = useMemberStore.getState().members.find((x) => x.id === cur.id);
|
||||
const members = memoryMember ? [memoryMember] : await loadMembersForAuth();
|
||||
const member = members.find((x) => x.id === cur.id);
|
||||
if (!member) return;
|
||||
|
||||
const next = toAuthUser(member);
|
||||
useAuthStore.setState({ user: next });
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
||||
if (localStorage.getItem(PERSIST_KEY)) {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { create } from 'zustand';
|
||||
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
|
||||
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
|
||||
|
||||
const STORAGE_KEY = 'ftb_members_v1';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
const PRESET_DEPARTMENTS: Department[] = [
|
||||
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
|
||||
@@ -25,14 +24,7 @@ const PRESET_ROLES: RoleItem[] = [
|
||||
];
|
||||
|
||||
const MOCK_MEMBERS: Member[] = [
|
||||
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024', createdAt: '2024-01-10' },
|
||||
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024', createdAt: '2024-01-12' },
|
||||
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024', createdAt: '2024-01-15' },
|
||||
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024', createdAt: '2024-02-01' },
|
||||
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024', createdAt: '2024-02-10' },
|
||||
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024', createdAt: '2024-02-15' },
|
||||
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024', createdAt: '2024-03-01' },
|
||||
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
|
||||
{ id: 'm-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
|
||||
];
|
||||
|
||||
interface MemberState {
|
||||
@@ -54,14 +46,13 @@ interface MemberState {
|
||||
deleteRole: (id: string) => void;
|
||||
}
|
||||
|
||||
function saveLocal(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
|
||||
function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
|
||||
saveServerData('members', state).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null {
|
||||
async function loadStored(): Promise<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -73,8 +64,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
passwordRule: DEFAULT_PASSWORD_RULE,
|
||||
loading: false,
|
||||
|
||||
fetchMembers: () => {
|
||||
const cached = loadLocal();
|
||||
fetchMembers: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) {
|
||||
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
|
||||
const migratedRoles = cached.roles.map((r) => {
|
||||
@@ -89,41 +80,41 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
|
||||
updatePasswordRule: (rule) => {
|
||||
set({ passwordRule: rule });
|
||||
saveLocal({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule });
|
||||
saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule });
|
||||
},
|
||||
|
||||
createDepartment: (data) => {
|
||||
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const departments = [...get().departments, dept];
|
||||
set({ departments });
|
||||
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
updateDepartment: (id, data) => {
|
||||
const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d);
|
||||
set({ departments });
|
||||
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
deleteDepartment: (id) => {
|
||||
const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id);
|
||||
set({ departments });
|
||||
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
|
||||
createMember: (data) => {
|
||||
const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const members = [...get().members, member];
|
||||
set({ members });
|
||||
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
updateMember: (id, data) => {
|
||||
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
|
||||
set({ members });
|
||||
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
deleteMember: (id) => {
|
||||
const members = get().members.filter((m) => m.id !== id);
|
||||
set({ members });
|
||||
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
|
||||
createRole: (data) => {
|
||||
@@ -135,7 +126,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
};
|
||||
const roles = [...get().roles, role];
|
||||
set({ roles });
|
||||
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
updateRole: (id, data) => {
|
||||
const roles = get().roles.map((r) => {
|
||||
@@ -148,11 +139,11 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
return { ...r, ...data };
|
||||
});
|
||||
set({ roles });
|
||||
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
deleteRole: (id) => {
|
||||
const roles = get().roles.filter((r) => r.id !== id);
|
||||
set({ roles });
|
||||
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -3,8 +3,7 @@ import { create } from 'zustand';
|
||||
import type { OvertimeRecord } from '@/lib/overtime';
|
||||
import type { DictItem } from '@/lib/requirement';
|
||||
import { calcDuration } from '@/lib/overtime';
|
||||
|
||||
const STORAGE_KEY = 'ftb_overtime_v1';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
const PRESET_REASONS: DictItem[] = [
|
||||
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
|
||||
@@ -20,19 +19,13 @@ const PRESET_REASONS: DictItem[] = [
|
||||
{ id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' },
|
||||
];
|
||||
|
||||
const MOCK_RECORDS: OvertimeRecord[] = [
|
||||
{ id: 'ot-1', projectId: 'p1', versionId: 'v2', person: '张三', startTime: '2024-12-10T19:00', endTime: '2024-12-10T22:30', duration: 3.5, reasonId: 'reason-3', createdAt: '2024-12-10' },
|
||||
{ id: 'ot-2', projectId: 'p2', versionId: 'v5', person: '李四', startTime: '2024-12-11T18:30', endTime: '2024-12-11T21:00', duration: 2.5, reasonId: 'reason-1', createdAt: '2024-12-11' },
|
||||
{ id: 'ot-3', projectId: 'p1', versionId: 'v2', person: '王五', startTime: '2024-12-12T19:00', endTime: '2024-12-12T23:00', duration: 4, reasonId: 'reason-4', createdAt: '2024-12-12' },
|
||||
{ id: 'ot-4', projectId: 'p3', person: '张三', startTime: '2024-12-13T18:00', endTime: '2024-12-13T20:30', duration: 2.5, reasonId: 'reason-7', createdAt: '2024-12-13' },
|
||||
{ id: 'ot-5', projectId: 'p2', versionId: 'v5', person: '赵六', startTime: '2024-12-14T19:30', endTime: '2024-12-15T01:00', duration: 5.5, reasonId: 'reason-5', remark: '线上紧急事故', createdAt: '2024-12-14' },
|
||||
];
|
||||
const MOCK_RECORDS: OvertimeRecord[] = [];
|
||||
|
||||
interface OvertimeState {
|
||||
records: OvertimeRecord[];
|
||||
reasons: DictItem[];
|
||||
loading: boolean;
|
||||
fetchRecords: () => void;
|
||||
fetchRecords: () => Promise<void>;
|
||||
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
|
||||
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
|
||||
deleteRecord: (id: string) => void;
|
||||
@@ -41,14 +34,13 @@ interface OvertimeState {
|
||||
deleteReason: (id: string) => void;
|
||||
}
|
||||
|
||||
function saveLocal(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
|
||||
function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
|
||||
saveServerData('overtime', data).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): { records: OvertimeRecord[]; reasons: DictItem[] } | null {
|
||||
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -58,8 +50,8 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
reasons: PRESET_REASONS,
|
||||
loading: false,
|
||||
|
||||
fetchRecords: () => {
|
||||
const cached = loadLocal();
|
||||
fetchRecords: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ records: cached.records, reasons: cached.reasons });
|
||||
},
|
||||
|
||||
@@ -73,7 +65,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
};
|
||||
const records = [...get().records, record];
|
||||
set({ records });
|
||||
saveLocal({ records, reasons: get().reasons });
|
||||
saveStored({ records, reasons: get().reasons });
|
||||
},
|
||||
|
||||
updateRecord: (id, data) => {
|
||||
@@ -86,31 +78,31 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
return updated;
|
||||
});
|
||||
set({ records });
|
||||
saveLocal({ records, reasons: get().reasons });
|
||||
saveStored({ records, reasons: get().reasons });
|
||||
},
|
||||
|
||||
deleteRecord: (id) => {
|
||||
const records = get().records.filter((r) => r.id !== id);
|
||||
set({ records });
|
||||
saveLocal({ records, reasons: get().reasons });
|
||||
saveStored({ records, reasons: get().reasons });
|
||||
},
|
||||
|
||||
addReason: (name) => {
|
||||
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const reasons = [...get().reasons, item];
|
||||
set({ reasons });
|
||||
saveLocal({ records: get().records, reasons });
|
||||
saveStored({ records: get().records, reasons });
|
||||
},
|
||||
|
||||
updateReason: (id, name) => {
|
||||
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
|
||||
set({ reasons });
|
||||
saveLocal({ records: get().records, reasons });
|
||||
saveStored({ records: get().records, reasons });
|
||||
},
|
||||
|
||||
deleteReason: (id) => {
|
||||
const reasons = get().reasons.filter((r) => r.id !== id);
|
||||
set({ reasons });
|
||||
saveLocal({ records: get().records, reasons });
|
||||
saveStored({ records: get().records, reasons });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { Product } from '@ftb/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import type { Stage, Role } from '@/lib/stage';
|
||||
import type { Priority, VersionLinks } from '@/lib/derive';
|
||||
|
||||
@@ -32,20 +34,7 @@ interface VersionItem {
|
||||
links?: VersionLinks;
|
||||
}
|
||||
|
||||
const MOCK_OVERVIEW: ProductOverview[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
name: '翻台宝',
|
||||
description: '餐饮 SaaS 主产品,覆盖门店运营管理全流程',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-06-01',
|
||||
projects: [
|
||||
{ id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' },
|
||||
],
|
||||
versions: [],
|
||||
_count: { requirements: 10, projects: 1, versions: 0 },
|
||||
},
|
||||
];
|
||||
const MOCK_OVERVIEW: ProductOverview[] = [];
|
||||
|
||||
interface ProductWithCount {
|
||||
id: string;
|
||||
@@ -101,8 +90,8 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
|
||||
fetchOverview: async () => {
|
||||
if (get().overview.length > 0) return;
|
||||
const cached = loadLocal();
|
||||
if (cached.length > 0) {
|
||||
const cached = await loadStoredOverview();
|
||||
if (cached) {
|
||||
// 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地)
|
||||
set({ overview: cached, loading: false });
|
||||
return;
|
||||
@@ -111,10 +100,11 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
try {
|
||||
const overview = await api.get<ProductOverview[]>('/products/overview');
|
||||
set({ overview, loading: false });
|
||||
saveLocal(overview);
|
||||
if (shouldPersistRemoteOverview(overview)) {
|
||||
saveStoredOverview(overview);
|
||||
}
|
||||
} catch {
|
||||
set({ overview: MOCK_OVERVIEW, error: null, loading: false });
|
||||
saveLocal(MOCK_OVERVIEW);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -147,7 +137,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
const updated = [newProduct, ...get().overview];
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
// 异步尝试同步远端(失败也无所谓,本地已更新)
|
||||
try { await api.post('/products', data); } catch {}
|
||||
},
|
||||
@@ -161,7 +151,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
p.id === id ? { ...p, ...data } : p,
|
||||
);
|
||||
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),
|
||||
overview: updated,
|
||||
});
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
reorderProducts: (ids) => {
|
||||
const map = new Map(get().overview.map((p) => [p.id, p]));
|
||||
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
||||
set({ overview: reordered });
|
||||
saveLocal(reordered);
|
||||
saveStoredOverview(reordered);
|
||||
},
|
||||
|
||||
migrateAndDeleteProduct: (sourceId, targetId) => {
|
||||
@@ -204,7 +194,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
.filter((p) => p.id !== sourceId)
|
||||
.map((p) => (p.id === targetId ? migratedTarget : p));
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
createProject: (productId, data) => {
|
||||
@@ -223,7 +213,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
createVersion: (productId, data) => {
|
||||
@@ -244,7 +234,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
updateVersion: (productId, versionId, data) => {
|
||||
@@ -258,7 +248,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
deleteVersion: (productId, versionId) => {
|
||||
@@ -271,7 +261,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
|
||||
deleteProject: (productId, projectId) => {
|
||||
@@ -284,20 +274,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
};
|
||||
});
|
||||
set({ overview: updated });
|
||||
saveLocal(updated);
|
||||
saveStoredOverview(updated);
|
||||
},
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = 'ftb_products_overview_v4';
|
||||
|
||||
function saveLocal(data: ProductOverview[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
|
||||
function saveStoredOverview(data: ProductOverview[]) {
|
||||
saveServerData('products-overview', data).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): ProductOverview[] {
|
||||
async function loadStoredOverview(): Promise<ProductOverview[] | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<ProductOverview[]>('products-overview');
|
||||
} catch {}
|
||||
return MOCK_OVERVIEW;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
interface RequirementState {
|
||||
requirements: Requirement[];
|
||||
@@ -10,7 +11,7 @@ interface RequirementState {
|
||||
types: DictItem[];
|
||||
platforms: DictItem[];
|
||||
loading: boolean;
|
||||
fetchRequirements: () => void;
|
||||
fetchRequirements: () => Promise<void>;
|
||||
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
|
||||
updateRequirement: (id: string, data: Partial<Requirement>) => void;
|
||||
deleteRequirement: (id: string) => void;
|
||||
@@ -50,33 +51,17 @@ const PRESET_PLATFORMS: DictItem[] = [
|
||||
|
||||
// --- Mock requirements ---
|
||||
|
||||
const MOCK_REQUIREMENTS: Requirement[] = [
|
||||
{ id: 'req-1', code: 'REQ-001', title: '值班排班自动生成', description: '根据员工可用时间自动排班,支持轮换规则', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
|
||||
{ id: 'req-2', code: 'REQ-002', title: '值班交接确认', description: '交接班时双方确认签到,记录交接内容', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'internal', sourceTarget: '门店经理', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
|
||||
{ id: 'req-3', code: 'REQ-003', title: '值班异常提醒', description: '迟到、缺勤自动推送提醒给店长', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P0', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌A', platforms: ['platform-1', 'platform-2'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-02' },
|
||||
{ id: 'req-4', code: 'REQ-004', title: '值班日历视图', description: '以日历形式展示当月值班安排', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '张三', createdAt: '2024-06-03' },
|
||||
{ id: 'req-5', code: 'REQ-005', title: '值班换班申请', description: '员工可发起换班申请,经理审批', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌B', platforms: ['platform-1'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-03' },
|
||||
{ id: 'req-6', code: 'REQ-006', title: '值班统计报表', description: '按月/周统计各员工值班时长、缺勤次数', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-3', creator: '张三', createdAt: '2024-06-04' },
|
||||
{ id: 'req-7', code: 'REQ-007', title: '多门店值班管理', description: '区域经理可查看管辖所有门店值班情况', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'XL', sourceType: 'management', sourceTarget: '区域总监', platforms: ['platform-1', 'platform-3'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-05' },
|
||||
{ id: 'req-8', code: 'REQ-008', title: '值班备注功能', description: '排班时可添加备注(如特殊事项提醒)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P3', effort: 'S', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '李四', createdAt: '2024-06-05' },
|
||||
{ id: 'req-9', code: 'REQ-009', title: '法定节假日排班规则', description: '节假日自动应用特殊排班规则(加班费标记)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'operation', sourceTarget: '合规部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
|
||||
{ id: 'req-10', code: 'REQ-010', title: '值班模板管理', description: '创建值班模板供快速复用(如早中晚三班)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
|
||||
];
|
||||
const MOCK_REQUIREMENTS: Requirement[] = [];
|
||||
|
||||
// --- localStorage helpers ---
|
||||
// --- Server persistence helpers ---
|
||||
|
||||
const STORAGE_KEY = 'ftb_requirements_v3';
|
||||
|
||||
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
|
||||
saveServerData('requirements', state).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null {
|
||||
async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -91,8 +76,8 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
platforms: PRESET_PLATFORMS,
|
||||
loading: false,
|
||||
|
||||
fetchRequirements: () => {
|
||||
const cached = loadLocal();
|
||||
fetchRequirements: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) {
|
||||
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms });
|
||||
}
|
||||
@@ -110,21 +95,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
};
|
||||
const updated = [...requirements, newReq];
|
||||
set({ requirements: updated });
|
||||
saveLocal({ requirements: updated, sourceTargets, types, platforms });
|
||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||
},
|
||||
|
||||
updateRequirement: (id, data) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
|
||||
set({ requirements: updated });
|
||||
saveLocal({ requirements: updated, sourceTargets, types, platforms });
|
||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||
},
|
||||
|
||||
deleteRequirement: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = requirements.filter((r) => r.id !== id);
|
||||
set({ requirements: updated });
|
||||
saveLocal({ requirements: updated, sourceTargets, types, platforms });
|
||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||
},
|
||||
|
||||
// --- SourceTarget dict ---
|
||||
@@ -133,21 +118,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const updated = [...sourceTargets, item];
|
||||
set({ sourceTargets: updated });
|
||||
saveLocal({ requirements, sourceTargets: updated, types, platforms });
|
||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||
},
|
||||
|
||||
updateSourceTarget: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
|
||||
set({ sourceTargets: updated });
|
||||
saveLocal({ requirements, sourceTargets: updated, types, platforms });
|
||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||
},
|
||||
|
||||
deleteSourceTarget: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = sourceTargets.filter((s) => s.id !== id);
|
||||
set({ sourceTargets: updated });
|
||||
saveLocal({ requirements, sourceTargets: updated, types, platforms });
|
||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||
},
|
||||
|
||||
// --- Type dict ---
|
||||
@@ -156,21 +141,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const updated = [...types, item];
|
||||
set({ types: updated });
|
||||
saveLocal({ requirements, sourceTargets, types: updated, platforms });
|
||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||
},
|
||||
|
||||
updateType: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
|
||||
set({ types: updated });
|
||||
saveLocal({ requirements, sourceTargets, types: updated, platforms });
|
||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||
},
|
||||
|
||||
deleteType: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = types.filter((t) => t.id !== id);
|
||||
set({ types: updated });
|
||||
saveLocal({ requirements, sourceTargets, types: updated, platforms });
|
||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||
},
|
||||
|
||||
// --- Platform dict ---
|
||||
@@ -179,21 +164,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
||||
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const updated = [...platforms, item];
|
||||
set({ platforms: updated });
|
||||
saveLocal({ requirements, sourceTargets, types, platforms: updated });
|
||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||
},
|
||||
|
||||
updatePlatform: (id, name) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
|
||||
set({ platforms: updated });
|
||||
saveLocal({ requirements, sourceTargets, types, platforms: updated });
|
||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||
},
|
||||
|
||||
deletePlatform: (id) => {
|
||||
const { requirements, sourceTargets, types, platforms } = get();
|
||||
const updated = platforms.filter((p) => p.id !== id);
|
||||
set({ platforms: updated });
|
||||
saveLocal({ requirements, sourceTargets, types, platforms: updated });
|
||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
||||
import { PRESET_CATEGORIES } from '@/lib/task-category';
|
||||
import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
const STORAGE_KEY = 'ftb_task_categories_v1';
|
||||
|
||||
function saveLocal(items: TaskCategory[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
|
||||
function saveStored(items: TaskCategory[]) {
|
||||
saveServerData('task-categories', items).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): TaskCategory[] | null {
|
||||
async function loadStored(): Promise<TaskCategory[] | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<TaskCategory[]>('task-categories');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TaskCategoryState {
|
||||
categories: TaskCategory[];
|
||||
fetchCategories: () => void;
|
||||
fetchCategories: () => Promise<void>;
|
||||
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
|
||||
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
|
||||
deleteCategory: (id: string) => boolean;
|
||||
@@ -28,15 +26,16 @@ interface TaskCategoryState {
|
||||
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
categories: PRESET_CATEGORIES,
|
||||
|
||||
fetchCategories: () => {
|
||||
const cached = loadLocal();
|
||||
if (cached) set({ categories: cached });
|
||||
fetchCategories: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ categories: normalizeTaskCategories(cached) });
|
||||
},
|
||||
|
||||
addCategory: (name, group, color) => {
|
||||
const list = get().categories;
|
||||
const item: TaskCategory = {
|
||||
id: `cat-${Date.now()}`,
|
||||
code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').replace(/^_+|_+$/g, '') || `cat_${Date.now()}`,
|
||||
name,
|
||||
group,
|
||||
color,
|
||||
@@ -45,13 +44,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
};
|
||||
const updated = [...list, item];
|
||||
set({ categories: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
updateCategory: (id, data) => {
|
||||
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
|
||||
set({ categories: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
deleteCategory: (id) => {
|
||||
@@ -59,7 +58,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
if (!target || target.isSystem) return false;
|
||||
const updated = get().categories.filter((c) => c.id !== id);
|
||||
set({ categories: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { TaskWorklog } from '@/lib/task-worklog';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
|
||||
const STORAGE_KEY = 'ftb_task_worklogs_v1';
|
||||
|
||||
function saveLocal(items: TaskWorklog[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
|
||||
function saveStored(items: TaskWorklog[]) {
|
||||
saveServerData('task-worklogs', items).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): TaskWorklog[] | null {
|
||||
async function loadStored(): Promise<TaskWorklog[] | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<TaskWorklog[]>('task-worklogs');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TaskWorklogState {
|
||||
worklogs: TaskWorklog[];
|
||||
fetchWorklogs: () => void;
|
||||
fetchWorklogs: () => Promise<void>;
|
||||
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
|
||||
deleteWorklog: (id: string) => void;
|
||||
getActualHours: (taskId: string) => number;
|
||||
@@ -27,8 +25,8 @@ interface TaskWorklogState {
|
||||
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
worklogs: [],
|
||||
|
||||
fetchWorklogs: () => {
|
||||
const cached = loadLocal();
|
||||
fetchWorklogs: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ worklogs: cached });
|
||||
},
|
||||
|
||||
@@ -40,13 +38,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
};
|
||||
const updated = [...get().worklogs, item];
|
||||
set({ worklogs: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
deleteWorklog: (id) => {
|
||||
const updated = get().worklogs.filter((w) => w.id !== id);
|
||||
set({ worklogs: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
getActualHours: (taskId) => {
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||
|
||||
const STORAGE_KEY = 'ftb_version_plans_v1';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
|
||||
const MOCK_PLANS: VersionPlan[] = [];
|
||||
|
||||
function saveLocal(plans: VersionPlan[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {}
|
||||
function saveStored(plans: VersionPlan[]) {
|
||||
saveServerData('version-plans', plans).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): VersionPlan[] | null {
|
||||
async function loadStored(): Promise<VersionPlan[] | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<VersionPlan[]>('version-plans');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VersionPlanState {
|
||||
plans: VersionPlan[];
|
||||
fetchPlans: () => void;
|
||||
fetchPlans: () => Promise<void>;
|
||||
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => 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;
|
||||
}
|
||||
|
||||
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
plans: MOCK_PLANS,
|
||||
|
||||
fetchPlans: () => {
|
||||
const cached = loadLocal();
|
||||
fetchPlans: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ plans: cached });
|
||||
},
|
||||
|
||||
@@ -39,7 +38,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||
const plans = [...get().plans, plan];
|
||||
set({ plans });
|
||||
saveLocal(plans);
|
||||
saveStored(plans);
|
||||
},
|
||||
|
||||
updatePlan: (id, data) => {
|
||||
@@ -56,18 +55,30 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
return { ...p, ...patch };
|
||||
});
|
||||
set({ plans });
|
||||
saveLocal(plans);
|
||||
saveStored(plans);
|
||||
},
|
||||
|
||||
completePlan: (id, result) => {
|
||||
const plans = get().plans.map((p) => p.id === id ? { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() } : p);
|
||||
let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' };
|
||||
const plans = get().plans.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
|
||||
const state = getPlanCompletionState(next);
|
||||
if (!state.canComplete) {
|
||||
response = { ok: false, message: state.missingReasons.join('、') || '计划未满足完成条件' };
|
||||
return p;
|
||||
}
|
||||
response = { ok: true };
|
||||
return next;
|
||||
});
|
||||
set({ plans });
|
||||
saveLocal(plans);
|
||||
saveStored(plans);
|
||||
return response;
|
||||
},
|
||||
|
||||
deletePlan: (id) => {
|
||||
const plans = get().plans.filter((p) => p.id !== id);
|
||||
set({ plans });
|
||||
saveLocal(plans);
|
||||
saveStored(plans);
|
||||
},
|
||||
}));
|
||||
|
||||
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user